news 2026/10/17 5:14:29

鸿蒙ArkUI组件封装实战:三招消除重复代码,提升复用性

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
鸿蒙ArkUI组件封装实战:三招消除重复代码,提升复用性

最近在做一个鸿蒙原生应用,页面一多,代码里最扎眼的问题就浮出来了:同一个卡片组件在三个页面里复制粘贴了四遍,搜索页和列表页各自实现了一套空状态和加载态,某个按钮的样式改了八处才对齐。说实话,ArkUI这套声明式语法上手不难,但如果你没有在项目初期就想清楚组件怎么抽、怎么封、怎么复用,后面每一行重复代码都会变成改版时的心头刺。

鸿蒙ArkUI的组件封装,本质上跟其他前端框架的思路类似,但又有它自己的脾气:状态管理、父子通信、组件复用机制都有专属的写法。这篇文章我不谈高大上的架构设计,就讲我自己在项目里反复用到的三招——用组合拆分把页面骨架和业务逻辑解耦,用@Builder/@Styles/@Extend把样式和布局参数化,再用@Reusable和通用基类把高频能力收编。每一招我都会给出能直接跑的代码和设计理由,也会说说边界在哪、哪些坑我替你踩过了。

如果你手里正好有鸿蒙ArkTS项目,或者正准备把一堆重复页面收拾干净,这篇应该能让你少走不少弯路。

1. 重复代码从哪来:一个列表页引发的"封装需求觉醒"

先说一个我处理过的真实场景,你大概率也遇到过。某项目的首页要展示一列商品卡片,卡片长这样:左边商品图,右边是标题、价格和两个操作按钮,加载完数据后要处理加载中转圈、接口报错、数据为空三种状态,还要支持下拉刷新和上滑加载更多。第一版我没多想,直接在页面里把卡片的所有元素写出来,大概300行,跑通了,OK。

然后第二个页面来了,叫"收藏列表",数据结构几乎一样,卡片就是多了一个"取消收藏"按钮,其他逻辑照搬。再然后是"历史浏览"页面,卡片又用了一次,这次按钮换成了"再次购买"。等做到第四个页面的时候,我回头一看,四个页面里躺着四份几乎一模一样的卡片代码,每份都带自己的loading判断和空状态UI。从那以后,每次改卡片的圆角、字号,或者给卡片加一个新品标签,我就得在四个地方同时改,改漏一个还得等测试报出来。

这个经历基本概括了大多数项目里重复代码的三种来源:

  • 同一个视觉组件在多处出现,复制的时候顺手就粘走了。
  • 加载态、错误态、空态这种通用状态,每写一个页面就重新画一遍。
  • 页面级公共逻辑,比如数据拉取后的状态切换、通用交互反馈,散落在各个文件里。

ArkUI给了我们声明式UI和组件的概念,但不代表你只要用了组件化写法就能自动消灭重复。恰恰因为ArkUI入门快,很多人会下意识把组件当"模板"用,在页面里直接堆砌UI元素,却没有真正设计过组件的边界和复用性。等到代码量上来,重构成本就高了。

所以封装不是炫技,也不是为了代码量好看,而是为了让你改一个东西时只改一处,加一个功能时不用动十个文件。下面这三招,就是我在这类重复场景里反复用、而且验证过有效的方法。

2. 第一招:用组合拆分把页面骨架与业务逻辑解耦

这一招解决的是"同一个UI结构换不同业务内容"的问题。拿上面说的卡片举例,四个页面的区别只是按钮文案、点击事件和数据来源,卡片本身的布局、样式、图片加载逻辑完全一致。你的第一反应可能是做一个通用的Card组件,然后通过参数控制按钮显示什么。这个思路能用,但参数会越加越多,最后变成一个if-else堆满的"万能组件"。

2.1 一个典型的重复场景:卡片列表三兄弟

先看我第一版的做法,非常典型:

@Component export struct ProductCard { @Prop productName: string = ''; @Prop price: string = ''; @Prop btnText: string = ''; @Prop showFavorite: boolean = false; @State isFavorite: boolean = false; build() { Row() { Image(this.productImg) .width(100).height(100).borderRadius(8) Column() { Text(this.productName).fontSize(16).fontWeight(FontWeight.Medium) Text(this.price).fontSize(14).fontColor('#FF7500') Row() { Button(this.btnText) .onClick(() => this.onBtnClick()) if (this.showFavorite) { Button('取消收藏').onClick(() => this.onFavoriteClick()) } } } } .padding(12) .backgroundColor(Color.White) .borderRadius(12) .margin({ bottom: 10 }) } }

这个组件已经做了参数化,但你看它别扭在哪?每个页面的按钮行为都不一样,你得通过onBtnClick回调把逻辑抛出去,如果两个操作都要暴露,就得再加一个回调。收藏按钮在某些页面有,某些页面没有,于是又加了一个showFavorite布尔开关。这还只是三个页面,等哪天需要给卡片加个"抢购倒计时"或者"评分星标",你会发现这个组件的参数列表变得无比臃肿。

2.2 组合拆分的落地形态:Component + 模板插槽

第二种思路,也是我推荐的做法,是把卡片的"骨架"拆出来,业务部分通过@BuilderParam插槽让外部传入。这就像搭乐高:底座是通用的,上面放什么积木由调用方自己决定。

@Component export struct CardContainer { @BuilderParam contentBuilder: () => void; build() { Row() { // 左侧图片区域统一处理 Image(this.imgUrl) .width(100).height(100) .borderRadius(8) .objectFit(ImageFit.Cover) // 右侧内容区完全交给外部 Column() { this.contentBuilder() } .layoutWeight(1) .alignItems(HorizontalAlign.Start) } .padding(12) .backgroundColor(Color.White) .borderRadius(12) .margin({ bottom: 10 }) .shadow({ radius: 4, color: '#0F000000', offsetY: 2 }) } }

调用方使用的时候,只需要按自己的页面定义build函数:

build() { List() { ListItem() { CardContainer({ imgUrl: this.item.imgUrl }) { Text(this.item.title) .fontSize(16) .fontWeight(FontWeight.Medium) Text('¥' + this.item.price) .fontSize(14) .fontColor('#FF7500') Button(this.item.hasFaved ? '取消收藏' : '收藏') .onClick(() => this.toggleFav(this.item)) } } } }

对比一下就会发现,CardContainer只关心卡片的外框视觉和基础布局,具体展示什么内容、放什么操作按钮,完全由页面决定。这就把一个容易膨胀的万能组件,变成了一个稳定的小组件加四个轻量页面组件。以后想统一卡片圆角、间距、阴影,只需要改CardContainer一个文件。

2.3 这招真正解决的问题与边界

用插槽改造之后,代码量看起来并没有立刻减少,甚至第一遍还会多写几个@BuilderParam。但它的价值在改动的时候会体现出来:UI结构变了只改一处,业务方互不影响。我在实际使用中还发现一个额外收益——单元测试和调试变简单了。以前出bug你得在万能组件里翻if-else分支,现在直接去对应页面查业务代码,定位快很多。

这一招的边界也值得说说。如果卡片只有一种形态,未来也很少扩展,那么直接参数化就行,不需要为了抽象而抽象。如果两种卡片形态差异很大(比如一个是横排一个是竖排),不要硬塞进同一个容器,宁可拆成两个容器组件,各用各的插槽。组合拆分的核心是"稳定的骨架"和"多变的内容"分开,而不是把所有相似的东西都凑进一个文件里。

3. 第二招:用@Builder/@Styles/@Extend把样式与布局"参数化"

组件拆完了,接下来是更细的粒度:一个页面里那些反复出现的小样式、小组件。比如搜索框、价格文本、标签胶囊、页面空白占位图,这些不是一个完整业务组件,却在一个项目里出现几十次。最笨的做法是每个页面复制一遍属性堆叠,稍微好一点的是抽成公共函数,但ArkUI给了我们三个更顺手的工具,用对了能极大减少样板代码。

3.1 三种能力定位

先分清这三个装饰器的分工,很多人容易混:

  • @Builder:用来封装一段UI结构,可以带参数,适合"一小块区域"的复用。它不只是样式复用,还有结构复用。
  • @Styles:用来封装属性集合,只能作用于单个组件,不能包含UI元素,适合"这组属性我到处要用"。
  • @Extend:跟@Styles类似,但是可以带参数,并且能针对某种具体组件类型定制。比如你只想让Text用某套样式,或者让Button根据状态变化用不同背景色。

一个粗略的选型判断:如果你要复用的是一个"区块",用@Builder;如果只是重复一串属性,用@Styles;如果这串属性需要根据参数动态变化,用@Extend。

3.2 一个含多种状态的按钮封装

拿最常见的按钮举例。项目里会有主按钮、次按钮、危险按钮,还有loading状态、禁用状态。你在每个页面里写五个button,每种状态好几个属性,代码量直接爆炸。用@Extend可以这样封装:

@Extend(Button) function commonButtonStyle(type: string, btnText: string) { .fontSize(16) .fontColor(Color.White) .backgroundColor(type === 'primary' ? '#007AFF' : (type === 'danger' ? '#E84026' : '#666666')) .borderRadius(20) .height(40) .width('100%') .opacity(this.disabled ? 0.5 : 1) .onClick(() => { if (this.disabled) { return; } this.onClickHandler(); }) }

不过需要注意一点,@Extend里的参数目前不能使用富表达式直接控制样式,有些复杂逻辑还是要靠三元表达式分层处理。实际操作中我更喜欢把按钮内部完全包成一个自定义组件,而不是单纯依赖@Extend,因为按钮往往还有内部loading圈和图标,这超出了@Extend能管的范围。@Extend更适合用在纯粹的视觉修饰上。

3.3 使用@Extend实现通用空白页/加载态

空白页是另一个重灾区。网络失败、空数据、无搜索结果,UI长得差不多,但每个页面都自己写一遍。如果你只用自定义组件,会需要传图标、主文案、副文案、按钮文案、点击回调五六个参数,略重。我一般用@Builder封装一个全局的PageStatusView,再配一份默认参数:

// 全局公共方法 @Builder export function PageStatusView(status: number, onRetry: () => void) { Column() { if (status === 0) { LoadingProgress().width(48).height(48) Text('加载中...').fontSize(14).fontColor('#999999').margin({ top: 12 }) } else if (status === 1) { Image($r('app.media.icon_network_error')).width(80).height(80) Text('网络异常,请稍后重试').fontSize(14).fontColor('#999999').margin({ top: 12 }) Button('重新加载').onClick(() => onRetry()).margin({ top: 16 }) } else if (status === 2) { Image($r('app.media.icon_empty')).width(80).height(80) Text('这里空空如也').fontSize(14).fontColor('#999999').margin({ top: 12 }) } } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) }

页面里直接用:

build() { Stack() { if (this.loading) { PageStatusView(0, () => {}) } else if (this.error) { PageStatusView(1, () => this.fetchData()) } else if (this.list.length === 0) { PageStatusView(2, () => {}) } else { List() { ... } } } }

这段代码你可能觉得似曾相识,它其实就是把最常用的页面骨架状态用公共方法固定下来,以后新页面直接把loading、error、empty三态判断贴过去,改改文案就行。我在某项目里做完这步之后,新来的同事写列表页基本不用碰UI框架,半天能出两个页面,效率提升很明显。

@Builder和@Extend这些能力,用的时候注意一个习惯:公共的放common目录下,页面私有的就近写在@Component内部就行,不要一股脑全塞进全局文件里,不然全局文件又会变成新的"重复代码垃圾场"。

4. 第三招:用@Reusable与通用基类把高频能力收编

前两招解决的是"UI怎么写",第三招解决的是"状态和逻辑怎么共用的"。ArkUI从API 9开始支持@Reusable组件复用,开发过列表的人都知道,列表一长就会卡,每个item都新建组件再销毁,开销非常大。这个机制的作用就是把划出屏幕的item实例回收到池子里,滑回来直接复用,避免反复创建和销毁。

4.1 列表项复用的价值

在ArkUI的List或者说Scroll容器里,如果你用普通自定义组件做多行列表,上下快速滑动时帧率可能会掉。改用@Reusable之后,配合aboutToReuse方法在复用前重置数据,明显会顺滑很多。写法上就是在组件结构体上挂个装饰器:

@Reusable @Component export struct ProductItem { @Prop item: ProductInfo; aboutToReuse(params: Record<string, Object>) { this.item = params.item as ProductInfo; } build() { Row() { // 复用时的UI } } }

这里有个容易被忽略的点:由于组件实例会被复用,之前的状态可能会残留,你必须实现aboutToReuse来重置所有跟业务相关的状态。我一开始忘了写这个,结果向下滑动后上滑回来,某些卡片还留着上一次的收藏状态。所以@Reusable不是加了就完事,它要求你对"哪些状态属于当前数据、哪些属于缓存"有清晰划分。

4.2 封装一个通用加载态组件

聊完列表,再讲逻辑收编。我项目里会做一个LoadStateView的通用状态容器,把loading、error、empty、content四种状态统一塞进去,页面只负责传数据,渲染层不用每次都写if-else:

@Component export struct LoadStateView { @Prop state: number; // 0: loading, 1: error, 2: empty, 3: content @Prop errorMsg: string = '网络异常,请稍后重试'; @BuilderParam contentBuilder: () => void; @Prop onRetry: () => void = () => {}; build() { Column() { if (this.state === 3) { this.contentBuilder() } else { PageStatusView(this.state, this.onRetry) } } } }

页面侧的使用就非常清爽了:

LoadStateView({ state: this.pageState, onRetry: () => this.fetchData() }) { List() { ... } }

这样一来,哪怕业务页面要加一个新的加载态分支,也只需要维护LoadStateView一个地方。这套通用状态容器配合第一招的CardContainer、第二招的PageStatusView,基本覆盖了我遇到的大部分列表页需求。

我在多端项目里验证过这个方案——同样的组件在一部中端配置的测试机上跑,长列表滑动帧率明显更稳,逻辑上也更容易维护。

4.3 Page基类与生命周期逻辑收拢

除了状态容器,还可以做一个页面级的基类:把通用的生命周期能力(onPageShow、onPageHide)、公共埋点上报、网络状态监听、鉴权判断统统放到基类里,子页面通过继承拿能力。ArkTS里自定义组件不支持多继承,但类继承是支持的,基类里可以存状态变量、写公共方法,子组件通过@Link或者@Prop拿到数据源。

举个例子,我做了一个BaseListPage,里面实现了初始化数据、分页参数管理、下拉刷新回调等逻辑,子页面只接入业务数据和UI骨架:

export class BaseListPage<T> { pageNum: number = 1; pageSize: number = 10; listData: T[] = []; hasMore: boolean = true; loadStatus: number = 0; async fetchPageData(): Promise<boolean> { // 通用分页拉取逻辑,子类重写请求部分即可 return true; } }

注意ArkTS对范型有约束,类泛型建议加约束T extends Object,实际操作时我遇到过编译报错,加个边界约束就好了。这个基类并不直接对应一个@Component,它适合处理"逻辑层"的复用,UI层仍然用组合或插槽来处理。

5. 封装之外的踩坑笔记:状态、父子通信与初始化顺序

三板斧用完之后,你大概率会在实际开发里遇到一些框架层面藏着的坑。这些坑不是封装思路的问题,而是ArkUI的一些使用细节,不提前了解,封装越深踩得越疼。

5.1 状态拆分:哪些放子组件里,哪些交给父组件

封装组件第一个难点就是状态放哪。如果你把业务数据全都放子组件里,那子组件就得自己拉接口,不但耦合业务,还会带来一个两难问题:多个页面场景不同,什么时候触发拉数据?什么时候刷新?把状态全放父组件,子组件就可能每条数据都要通过@Prop或@ObjectLink穿透,传值多且容易失效。

我的经验是:子组件只负责"展示状态",也就是@Prop或@State控制视觉;数据的加载、缓存、更新逻辑放父页面。比如一个商品卡片,isFavorited这个状态应该由父组件持有,通过@Link或事件回调传给子组件修改,不要子组件内部自己偷偷改数据。不然"收藏"这个操作在两个页面出现时,状态可能就不同步了。

5.2 父子通信四种方式怎么选

ArkUI里父子通信主要就四种形式:@Prop、@Link、@Event回调,以及@Provide/@Consume这种跨层级的方式。你如果试图只用@Prop传对象,那会遇到修改不生效的麻烦,因为@Prop是单向同步,子组件改了父组件不知道。用@Link双向绑定时,要注意它是引用类型同步,如果你在子组件里把这个对象整个重新赋值,父组件的引用也可能被替换掉,容易出问题。

有一个我踩过的坑:父组件把一个@State数组传给子组件做列表,子组件用@Link items: Array<Item>接收,然后在子组件里做splice删除。逻辑上看起来没问题,但如果你在方法里直接this.items = newArr,不仅自己页面数据走了,父组件里还留着旧引用,界面不刷新。正确的做法是父组件提供onDelete回调,由父组件来更新数组,保持"单一数据源"。

你可以在组件设计的一开始就约定好:"数据属父、事件回调"。子组件完全不通数据源,所有状态变更都通过回调让父组件做。这样测试的时候你直接调父组件的状态变更方法,不必猜子组件里干了什么。

5.3 组件初始化的两个常见坑

第一个坑是aboutToAppear和构造参数的先后顺序。如果你在子组件的aboutToAppear里读取@Prop的值,发现是个默认值,而不是父组件传进来的值,别惊讶,这个阶段子组件还没有拿到最终的初始数据。你需要用到aboutToReuse或者把初始化动作放到build完成之后的某个时机。这是ArkUI组件生命周期的一个特点,我第一次被它坑了一下午,后来在所有子组件里都写了一个"等待父组件数据就绪"的防御性判断,或者干脆在父组件用if控制子组件渲染时机。

第二个坑是@State初始化赋值时不能依赖其他@State。@State的初始值只会在组件创建时生效一次,后续如果父组件重新传值,@State并不会自动更新。如果你需要随外部变化而变化,应该用@Prop或@Link。很多时候"传了但界面不更新"就是这个原因:你把应该用@Prop的变量写成了@State。

6. 实测下来,这套套路到底省了多少代码

从代码量上看,我把同一个业务模块里四个页面的公共部分收拢后,卡片相关代码从每页300行降到每页120行左右,公共组件代码大约200行,总体代码量几乎减半。但说实话,组件封装更大的价值不在"删行数",而在"改一处就能同步四个页面"。

某次改版要统一调整所有卡片的外阴影和图片圆角,我只动了CardContainer一个文件;另一次全项目把空状态文案改成"这里暂时没有内容哦,去逛逛其他商品吧",我只需要改PageStatusView里的一行文本。这种改动成本下降,才是封装真正的意义。

如果再算上@Reusable带来的滑动体验提升,以及新页面直接复用骨架带来的开发速度,我觉得这三招投入产出比相当划算。当然,它不是银弹——一个项目如果页面形态差异极大、各自的样式和逻辑占比都很高,强行抽象公共组件反而会拖慢开发。封装的前提,是你确实看到了重复,而且这种重复是稳定的、有共性的。

做组件封装,最忌讳的是"为了封装而封装"。我在团队里给新人的建议很简单:先在三个地方复制过再动手抽象,或者等同一个组件改到第三次再考虑抽公共。过早抽象会让你在连需求都没看清的时候就定下了组件的边界,后面需求一变,这个"完美组件"就会变成最难改的东西。

最后再分享一个小技巧:公共组件里尽量少写魔法数字,多用枚举或者常量。比如状态值0/1/2/3,写的时候一时爽,换个人接手的第二天就会来问你0到底是loading还是empty。我后来都改成枚举了,代码可读性一下就上来了。

希望这三招能帮你在鸿蒙ArkUI项目里少写几行重复代码,也少踩几个我已经替你踩过的坑。如果你在封装过程中遇到其他问题,欢迎在评论区聊聊各自的解法,说不定你的思路正是别人缺的那块拼图。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 5:13:06

Google黑客语法实战:五大高级搜索技巧与站点安全自查指南

1. 先说清楚&#xff1a;这套“黑客语法”到底是在玩什么作为一名常年做安全评估和渗透测试的工程师&#xff0c;我几乎每天都会用搜索引擎来收集目标系统的公开信息。很多人听到“Google黑客语法”就觉得神秘&#xff0c;好像掌握了什么暗黑技术。其实拆开来看&#xff0c;它就…

作者头像 李华
网站建设 2026/10/17 5:13:05

Claude Code模板实战:用CLAUDE.md和斜杠命令打造高效AI编程助手

1. 这个模板项目到底解决了什么问题先交代一下背景。如果你过去半年关注过 AI 辅助编程&#xff0c;大概率已经见过 Claude Code 这个名字。它是 Anthropic 推出的命令行编程代理&#xff0c;能直接在你的仓库里读代码、改文件、跑命令、提 PR&#xff0c;不是那种"你在对…

作者头像 李华
网站建设 2026/10/17 5:13:00

OpenClaw智能代理解决方案部署实战:架构拆解与踩坑指南

2026年刚开年&#xff0c;我们团队就开始落地一套名为“OpenClaw”的智能代理解决方案。如果你关注过近两年的自动化运维和AI Agent方向&#xff0c;应该对这类“代理中间层”不陌生&#xff1a;它不是传统意义上的网络跳板工具&#xff0c;而是一个统一调度工具链、封装API调用…

作者头像 李华
网站建设 2026/10/17 5:12:56

PI-VLA深度解析:对称性感知的预测交互式视觉语言动作模型

PI-VLA这个标题最近在我关注的几个具身智能交流群里出现过好几次。坦白说&#xff0c;看到“Symmetry-Aware Predictive and Interactive Vision-Language-Action Framework”这一串限定词&#xff0c;我第一反应是&#xff1a;这又是把好几个热门概念缝合在一起的框架。真读下…

作者头像 李华
网站建设 2026/10/17 5:12:56

Python Web 生产环境部署:Docker 与 Nginx 从设计到实战全流程

我这些年帮人部署过不少Python Web应用&#xff0c;也见过太多“本地跑得欢&#xff0c;上线凉一半”的例子。开发环境和生产环境之间的差距&#xff0c;从来都不是两行pip install能填平的。Docker加上Nginx这套组合&#xff0c;几乎是目前Python Web上生产环境最主流、也最省…

作者头像 李华
网站建设 2026/10/17 5:12:46

Python具名参数实战:告别位置参数,让代码更清晰可维护

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华