
做鸿蒙应用开发也有段时间了回头看自己早期写的页面那叫一个“复制粘贴大法”——同样的标题栏、同样的卡片样式、同样的状态布局每写一个新页面就重来一遍。代码冗余还在其次最难受的是改需求换个圆角、加个阴影要把十几个文件翻出来逐一改。后来我把 ArkUI 的组件扩展体系系统梳理了一遍工程结构才算是彻底理顺。这篇文章不绕弯子直接把我实际用下来的组件扩展经验、代码写法、踩坑点全部摊开讲希望能帮到正在搞 HarmonyOS 应用的你。照例先给结论ArkUI 的组件扩展不是单一机制而是一套组合拳。自定义组件负责封装完整的业务模块Builder 处理高复用 UI 片段Extend 给系统组件扩展能力Styles 解决公共样式复用ReusableComponent 则应对列表性能优化。把这套东西搭配好代码量至少能砍掉三分之一后面维护起来的幸福感是完全不一样的。1. 组件化思维与扩展方式的整体选型讲具体代码之前得先把思路捋清楚。很多人一提到组件扩展就想着造轮子但 ArkUI 里的情况不太一样——它给了你好几套机制每套的定位和边界都不一样。选对了事半功倍选错了就是给后面埋雷。1.1 为什么一定要做组件扩展写过一段 ArkUI 的人大概都会有这种体验一个页面里的 Column 和 Row 套来套去Text、Image、Button 的样式重复率极其惊人。比如商品列表页每个商品卡片长一个样只是数据不同个人中心页几个功能入口的列表项结构也高度相似。把这些结构复制十几遍这不是勤劳是给自己找麻烦。组件扩展能解决的痛点非常明确代码复用同一个 UI 结构封装一次处处可用彻底告别复制粘贴。逻辑隔离每个组件管理自己的状态和业务逻辑页面不会变成一团乱麻。维护成本下降需求变更时只改一个地方不需要全工程搜索替换。性能可控配合组件复用机制列表这类高频刷新场景也能保持流畅。我见过有的项目把所有代码堆在一个 Entry 组件里一个文件几千行改一个按钮样式都心惊胆战。这种项目别说维护了光编译报错就够让人崩溃。组件化不是选做题是 ArkUI 工程开发的必答题。1.2 五种扩展方式的能力边界对比ArkUI 里最常用的扩展方式我总结下来就五种自定义组件、Builder、Extend、Styles、ReusableComponent。它们各有各的适用场景能力边界也不同。先把对比表格摆出来后面逐个细讲。扩展方式适用场景能否传参能否管理状态扩展对象推荐程度自定义组件完整业务模块封装支持Prop/Link等支持任意组合核心首选Builder高频UI片段复用支持不支持单独管理状态UI构建函数高频使用Extend给系统组件增加属性方法支持不支持系统组件Text/Column等常用Styles公共属性样式批量复用新版本支持不支持系统组件样式按需使用ReusableComponent列表滑动性能优化支持支持自定义组件列表场景推荐从表格能看出来真正能承担复杂业务逻辑的是自定义组件它是组件化的基石。Builder、Extend、Styles 更像是一种“轻量姿势”适合在单个页面内部做局部复用或者给全局样式铺底。理解了这个分层你就能明白为什么不是所有场景都得建一个独立组件文件。2. 自定义组件的正确打开方式自定义组件是 ArkUI 组件扩展体系里最重的一招也是每个鸿蒙开发者必须熟练掌握的基础能力。它有完整的生命周期能管理自己的状态能通过参数和父组件通信是构建复杂页面的根基。2.1 从 struct 到组件导出的基础写法ArkUI 里创建一个自定义组件很简单核心就两个装饰器Component 表示这是一个组件Entry 表示这是页面入口。而独立封装的业务组件一般不用 Entry只保留 Component然后 export 出去供其他文件使用。一个最基础的组件长这样Component export struct UserCard { Prop userName: string ; Prop avatarUrl: string ; build() { Row({ space: 12 }) { Image(this.avatarUrl) .width(48) .height(48) .borderRadius(24) .backgroundColor(#E8E8E8) Column({ space: 4 }) { Text(this.userName) .fontSize(16) .fontWeight(FontWeight.Medium) Text(在线) .fontSize(12) .fontColor(#999999) } .alignItems(HorizontalAlign.Start) } .width(100%) .padding(12) .backgroundColor(Color.White) .borderRadius(12) } }这里有几个初期容易踩的坑。struct 的成员变量必须初始化这是 ArkTS 的类型约束要求组件文件名和 struct 名可以不强制一致但为了查代码方便我建议保持一致UserCard 组件就放 UserCard.ets。另外组件内部 build() 方法里只能有一个根节点不能写多个平行的 Column 或 Row这点和 Flutter 的写法定级相似刚上手的人经常在这里报错。2.2 参数传递与状态同步Prop、Link、ObjectLink组件封装好了数据怎么进去、怎么出来这是自定义组件最核心的问题。ArkUI 提供了几种装饰器对应不同的数据传递场景。Prop 是一对一单向同步。父组件传值给子组件子组件内部修改不会反向影响父组件。适合纯展示型的数据。// 父组件中使用 UserCard({ userName: 张三, avatarUrl: https://xxx.png })Link 是双向同步。子组件改了值父组件那边的变量也会跟着变。适合需要在子组件里修改数据的场景比如输入框、开关切换。// 父组件 Component export struct ParentComp { State isSwitchOn: boolean false; build() { ChildComp({ isOn: $isSwitchOn }) } } // 子组件 Component export struct ChildComp { Link isOn: boolean; }注意点Link 传参时要带 $ 前缀例如$isSwitchOn这是 ArkUI 的引用传参语法。新手最容易漏掉这个 $一漏就编译不过。ObjectLink 配合 Observed 使用用来观察对象内部属性的变化。如果传的是一个对象数组或复杂对象用 Prop 是监听不到内部字段变更的必须上 ObjectLink。这套组合拳在列表数据更新时极其好用配合 ForEach 的 key 生成规则能做到精准刷新而不是整个列表重绘。2.3 将组件抽成独立文件的注意事项一个工程里组件多了以后文件组织就显得格外重要。我的习惯是建一个 components 目录按业务模块或通用程度分几个子目录。common/通用组件如 BaseHeader、CommonButton、EmptyView 这类跨业务使用的。business/和具体业务绑定的组件如商品卡片、订单状态条、评论列表项。page/某个页面独有的结构只在该页面内使用。组件文件里如果依赖了其他公共组件直接 import 进来就行。有一点要提醒公共组件尽量不要反过来依赖业务组件否则会形成循环依赖编译期可能不报错但运行时候会有各种诡异问题。还有自定义组件的 struct 建议使用 export 关键字导出这样在测试时也能单独引用。组件内部的状态变量命名尽量语义化避免用 a、b、c 这种缩写——组件一旦抽成公共模块阅读者可能完全不认识你的业务上下文命名就是最好的注释。3. 用 Builder 和 Extend 解决轻量级扩展问题自定义组件虽然强大但不是所有场景都值得专门建一个 .ets 文件。有些时候你只是想把一段高频出现的 UI 片段提炼出来或者给 Text 组件统一加一个样式这时候用 Builder 和 Extend 这类轻量方案反而更合适。它们不需要维护组件的生命周期也不用考虑状态管理纯函数式的写法非常轻快。3.1 Builder把高频 UI 片段变成函数Builder 装饰器是 ArkUI 中用来定义“构建函数”的。说白了就是把一段重复使用的 UI 结构抽成一个函数在 build() 里直接调用。它支持参数能根据参数渲染不同的内容。比如一个页面里有好几处需要展示标签每个标签的颜色和文字不同Builder function renderTag(text: string, bgColor: string) { Text(text) .fontSize(12) .fontColor(Color.White) .backgroundColor(bgColor) .borderRadius(4) .padding({ left: 8, right: 8, top: 2, bottom: 2 }) } Component export struct DemoPage { build() { Column() { // 使用 renderTag(优惠, #FF7500) renderTag(新品, #3A7DFF) } } }Builder 最大的价值在于它的灵活度它不需要单独写一个 struct不需要考虑状态管理任何时候想用就直接调用传参进去渲染。它和自定义组件最大的区别是——它“无状态”。如果这个 UI 片段需要管理自己的状态那就该用自定义组件。不过 Builder 也有一个典型的坑。定义在组件外部的 Builder 函数如果在函数体内访问组件内部的状态变量会访问不到或者产生异常。我的建议是外部 Builder 尽量把需要的值都通过参数传进去不要隐式依赖外部状态。如果确实需要访问组件状态就把 Builder 定义在组件内部它就能词法捕获到 this 的作用域。3.2 Extend给系统组件开挂Extend 是我个人非常偏爱的一个机制。它能给系统组件扩展自定义的属性方法就像给 Text、Image、Column 这些基础组件“打补丁”一样。这在写公共样式时太好用了。比如全局统一的价格文字样式你可以这样扩展Extend(Text) function priceText(color: string #FF7500) { .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor(color) .margin({ right: 4 }) } // 使用 Text(¥99.9) .priceText(#FF7500)看见没Extend 定义好之后所有 Text 组件在构建时都能直接链式调用自定义的方法。开发者不需要再重复写 fontSize、fontWeight、fontColor 这一大串只需要一行.priceText()。这和我们以前写 CSS 时定义一个.price类名有异曲同工之妙。Extend 还支持传参可以根据参数动态设置值比固定样式灵活不少。需要注意几个限制Extend 只能扩展系统组件不能扩展自定义组件。你想给自定义组件加属性和方法得老老实实去改组件内部。Extend 函数体里面只能写“属性”不能在 Extend 里写 build() 结构。它不是用来定义新布局的而是用来给现有组件加样式的。Extend 支持全局定义也就是在 .ets 文件顶部定义工程内所有组件都能用。但要注意重名问题同名 Extend 会冲突建议取名带上业务前缀。3.3 Styles公共样式的复用如果说 Extend 是把“方法”挂在组件上那 Styles 就更像 CSS 里的“类选择器”。它把你常用的属性集合提炼成一个样式块然后用的时候直接引用。Styles function cardStyle() { .padding(12) .backgroundColor(Color.White) .borderRadius(12) .shadow({ radius: 6, color: #1A000000 }) } // 使用 Column() { // 内容 } .cardStyle()Styles 和 Extend 区别在哪Styles 不限定组件的类型它可以应用在 Column、Row、Text、Button 等各种系统组件上而 Extend(Text) 只能用于 Text。所以如果是跨组件类型通用的样式优先用 Styles如果确定只在某一类组件上用Extend 会更清晰一些。还有一个细节在新版本的 ArkTS 语法中Styles 也是支持带参数的但旧版本不支持函数体内只能写固定值。如果你们的项目用的 SDK 版本较老就先别在 Styles 里玩传参。写代码之前先查一下项目里 API version避免编译报错。4. 实战案例封装一个商品卡片组件聊完理论来点实际的。我用一个电商 App 里最常见的商品卡片来串一遍完整流程。这个案例覆盖了自定义组件、Prop 传参、事件回调、Builder 内部函数、ReusableComponent 复用等知识点把前面讲的内容串起来跑一遍。4.1 需求拆解先看卡片要展示什么内容商品图片、商品名称、商品价格、原价划线、标签比如“特惠”、加入购物车按钮。点卡片跳转详情页点按钮加入购物车并 toast 提示。拆解下来这个卡片是一个相对完整的业务模块适合用自定义组件封装。它的输入参数是一个商品对象输出行为就是“点击卡片”和“点击加购”两个事件。4.2 从零搭建组件先定义商品的数据结构这里是纯数据类不需要被观察的话直接用 class 定义就行export class Product { id: string ; title: string ; price: string ; originalPrice: string ; tag: string ; imageUrl: string ; }然后创建组件文件 ProductCard.etsimport { Product } from ../model/Product; import { promptAction } from kit.ArkUI; Component export struct ProductCard { Prop product: Product new Product(); onCardClick: () void () {}; onAddCartClick: (id: string) void () {}; Builder private renderTag(tag: string) { if (tag tag.length 0) { Text(tag) .fontSize(10) .fontColor(Color.White) .backgroundColor(#FF3B30) .borderRadius(4) .padding({ left: 4, right: 4, top: 1, bottom: 1 }) } } build() { Column({ space: 8 }) { Stack({ alignContent: Alignment.TopStart }) { Image(this.product.imageUrl) .width(100%) .height(140) .objectFit(ImageFit.Cover) .backgroundColor(#F0F0F0) .borderRadius(8) this.renderTag(this.product.tag) .margin({ left: 8, top: 8 }) } .width(100%) Text(this.product.title) .fontSize(14) .fontColor(#333333) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) Row() { Text(this.product.price) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor(#FF3B30) Text(this.product.originalPrice) .fontSize(12) .fontColor(#999999) .decoration({ type: TextDecorationType.LineThrough }) .margin({ left: 6 }) Blank() Button(加购) .height(28) .fontSize(12) .backgroundColor(#FF7500) .onClick(() { this.onAddCartClick(this.product.id); }) } .width(100%) } .padding(10) .backgroundColor(Color.White) .borderRadius(12) .shadow({ radius: 4, color: #14000000, offsetY: 2 }) .onClick(() { this.onCardClick(); }) } }几个值得展开讲讲的设计点。图片外层套了一个 Stack目的是让标签能够“压”在图片左上角而不是另起一行占位置。Button 的点击事件里不要直接写业务逻辑而是通过this.onAddCartClick(this.product.id)这个回调把事件抛给父组件。这样组件保持了纯净性父组件拿到 id 后想跳转、想弹窗、想调接口都行组件本身不需要关心业务。BuilderrenderTag定义在组件内部这样它可以直接访问 this.product.tag不需要通过参数递归传值。4.3 在页面中使用组件在商品列表页或者主页里用起来就非常清爽了import { ProductCard } from ./components/ProductCard; import { Product } from ./model/Product; Entry Component export struct HomePage { State productList: Product[] [...]; // 商品数据 build() { List({ space: 12 }) { ForEach(this.productList, (item: Product) { ListItem() { ProductCard({ product: item, onCardClick: () { // 跳转详情页逻辑 }, onAddCartClick: (id: string) { // 加购逻辑 } }) } }, (item: Product) item.id) } .padding(12) .backgroundColor(#F5F5F5) } }注意 ForEach 的第三个参数——key 生成函数。我在这里用的item.id保证列表做差量更新时能精确匹配到每个卡片。如果不用 key 或者 key 是随机值列表刷新时可能会整个重建性能大打折扣。4.4 性能优化ReusableComponent 复用列表页做到这一步功能上是没问题了但性能还可以再压一压。当列表很长、商品卡片超过一屏时每次滑动都会创建新的 ProductCard 实例滑动起来会感觉不够丝滑。ArkUI 为此提供了组件复用机制核心装饰器是 ReusableComponent。改造很简单两步第一步在 ProductCard 的 Component 装饰器上方加上 ReusableComponentReusableComponent Component export struct ProductCard { // ... 组件内容不变 }第二步在使用时通过 if/else 或者显式标记让组件进入复用池ListItem() { if (this.isFirstScreen) { ProductCard({ product: item, onCardClick: () { /* ... */ }, onAddCartClick: (id: string) { /* ... */ } }) .reuseId(product_card) } }核心机制是使用了.reuseId()的组件在滑出屏幕后不会立即销毁而是进入复用池当有同样的 reuseId 组件要滑入时直接从池子里取出来更新数据。这里有一个非常关键的钩子函数aboutToReuse它在组件被复用时触发你得在这里把新数据重新赋给组件内部的变量ReusableComponent Component export struct ProductCard { Prop product: Product new Product(); aboutToReuse(params: Recordstring, Object) { if (params.product) { this.product params.product as Product; } } build() { /* ... */ } }如果不实现 aboutToReuse组件虽然复用了但界面上的数据可能还是上一次的旧数据典型的花屏问题。这是复用组件最容易踩的坑后面排查章节我细说。5. 常见问题与排查技巧实录这部分写的都是我在实际项目中真实遇到过的问题每一个都折腾过不短时间。如果你在开发中也卡住了对照着查大概率能省下不少排查时间。5.1 Extend 不能用在哪Extend 虽然好用但它有两个天然的边界。第一个它只能扩展系统组件。我见过有人想用 Extend 给自定义组件加公共方法编译直接报错。替代方案是要么改成在自定义组件内部提取 Builder 方法要么用继承方案重新封装一个带样式的组件。第二个Extend 函数内部不能使用 State 之类的状态装饰器。它是个纯样式扩展不参与状态管理。如果你需要状态驱动样式变化请在调用组件那里动态传参。还有一个容易忽略的问题Extend 和 Styles 不能重名。全局定义一个Extend(Text) function priceText()后再定义一个Styles function priceText()就会冲突。公共文件里的命名最好统一风格建议加模块前缀。5.2 状态不刷新/参数不更新的排查自定义组件的状态不刷新是初学者问得最多的问题。这类问题我按排查顺序列一下。第一检查装饰器用的对不对。父组件改了值但子组件没变最常见的原因是用 State 而不是 Prop。State 是组件内部状态父组件传值进来它不一定感知要接收外部参数必须用 Prop、Link 或 ObjectLink。第二检查对象嵌套层级。如果传的是一个对象子组件定义了Prop product: Product父组件改了product.priceUI 没刷新——因为 Prop 只监听整个对象的引用变化不监听对象内部属性的变化。解决方式是改用ObjectLink Observed或者干脆用一个新的对象整体替换。第三检查双向同步传参有没有加$符号。Link 必须用$var传入漏了 $ 的后果是子组件里改了值父组件无动于衷。第四检查是否在 ForEach 中用了不稳定的 key。如果你的 key 生成函数返回的是随机数或者数组下标每次渲染都会产生“伪节点”状态刷新自然就不听话。记住key 要稳定、唯一、和业务数据强关联。5.3 组件复用时的坑ReusableComponent 能提升列表流畅度但它引入的问题也比较隐蔽我直接列出踩坑清单。漏写 aboutToReuse。组件被复用后不会重新走构造函数所有数据更新都得在 aboutToReuse 里手动赋值。没写等于用旧数据渲染新列表项这是花屏和错位的第一大原因。复用和 if 搭配不当。在 List 里用 if 条件渲染复用组件时条件分支切换会导致复用池体积膨胀。建议把复用的 if 条件控制得简单一点不要让多种状态频繁交叉切换。组件内部如果有全局单例或外部静态变量复用时这些状态不会自动重置容易串数据。嵌套子组件时如果只有外层加了 ReusableComponent内层子组件没有复用的颗粒度可能不够性能提升有限。理想的做法是内层子组件也加上 ReusableComponent让复用链路完整。我自己吃过最大的亏就是 aboutToReuse 漏写当时列表滑动一半突然出现几行数据错乱排查了一个下午才发现是复用组件没有更新数据。自从那次以后我给团队定的规矩就是所有 ReusableComponent 组件必须先写 aboutToReuse 再写 build。5.4 编译环境与版本差异最后说一个工程层面的问题。ArkUI 的语法一直在演进不同 API version 下装饰器的行为是有差异的。比如 Styles 是否能传参、Builder 的某些用法、以及 V2 状态管理下的 ComponentV2 / Local 等新装饰器都说跟 V1 有区别。如果你在开发中拉了一个新版本的 SDK 工程发现同样的代码编译不过不要太惊讶。先确认项目的 compileSdkVersion去官方 API Diff 里查当前版本的废弃和新增列表。我个人目前还是以 V1 装饰器为主因为稳定、文档多、团队熟悉。但如果你是新项目且团队经验足够可以试试 V2 的组件状态管理它的 Local 和 Param 在状态管控上确实更先进一些。写在最后我自己的习惯是页面骨架用自定义组件搭高频碎片逻辑用 Builder 提炼通用样式用 Styles 和 Extend 铺底列表性能焦虑了就上 ReusableComponent。这套组合拳打下来工程结构基本不会太乱。最后分享一个小技巧。组件扩展的粒度一定要把握好不是越细越好。我见过有人把一个 Text 都抽成组件结果列表里四五个组件的嵌套层级性能和可读性双双下跌。合理的粒度是能被语义化命名的、能在多处复用或者被独立测试的模块才有必要抽成组件。你在封装 ArkUI 组件时踩过什么印象深刻的坑欢迎在评论区分享出来我看到了会回。代码没有完美的但踩过的坑多了总能让后来的路顺畅一点。