最近在做一个鸿蒙原生应用页面一多代码里最扎眼的问题就浮出来了同一个卡片组件在三个页面里复制粘贴了四遍搜索页和列表页各自实现了一套空状态和加载态某个按钮的样式改了八处才对齐。说实话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: Recordstring, Object) { this.item params.item as ProductInfo; } build() { Row() { // 复用时的UI } } }这里有个容易被忽略的点由于组件实例会被复用之前的状态可能会残留你必须实现aboutToReuse来重置所有跟业务相关的状态。我一开始忘了写这个结果向下滑动后上滑回来某些卡片还留着上一次的收藏状态。所以Reusable不是加了就完事它要求你对哪些状态属于当前数据、哪些属于缓存有清晰划分。4.2 封装一个通用加载态组件聊完列表再讲逻辑收编。我项目里会做一个LoadStateView的通用状态容器把loading、error、empty、content四种状态统一塞进去页面只负责传数据渲染层不用每次都写if-elseComponent 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 BaseListPageT { pageNum: number 1; pageSize: number 10; listData: T[] []; hasMore: boolean true; loadStatus: number 0; async fetchPageData(): Promiseboolean { // 通用分页拉取逻辑子类重写请求部分即可 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: ArrayItem接收然后在子组件里做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项目里少写几行重复代码也少踩几个我已经替你踩过的坑。如果你在封装过程中遇到其他问题欢迎在评论区聊聊各自的解法说不定你的思路正是别人缺的那块拼图。