先说一个我自己的经历。去年做鸿蒙应用改版,项目里有个"带进度条的项目卡片"要在首页、分类页和搜索页三个地方出现,样式一样,只有旁边的按钮文案和点击回调不同。第一反应肯定是抽个自定义组件,但写了一半发现不对劲:为了一个卡片去定义@Component,要给每个字段都写@Prop、给回调写@Event,光样板代码就快一百行了,而这个卡片本身只是个静态展示加一个按钮。后来用@Builder重构,代码量直接砍掉一半。但真正让我卡壳的是另一个问题——同一个@Builder,写在不同位置到底有什么区别?组件内用和组件外用,是不是只是"放哪儿"的区别?
如果你也有同样的疑问,这篇内容应该能帮到你。我会从机制层面拆解@Builder,把组件内和组件外的差异、参数传值的坑、实际项目的选型习惯全部展开,最后附上我踩过的几个真实问题。这不是官方文档复读,是照着代码一行行调出来的经验。
1. @Builder到底是什么:先解决"什么时候用它"的疑问
1.1 从一段重复的卡片代码说起
先还原一个典型的重复场景。假设列表页里每个项目项都要渲染这样一个卡片:左侧封面图,中间标题和进度,右侧一个操作按钮。
@Entry @Component struct ProjectListPage { @State projects: ProjectModel[] = []; build() { List({ space: 12 }) { ForEach(this.projects, (item: ProjectModel) => { ListItem() { Row({ space: 12 }) { Image(item.cover) .width(72) .height(72) .borderRadius(8) Column({ space: 4 }) { Text(item.title) .fontSize(16) .fontWeight(FontWeight.Medium) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Progress({ value: item.progress, total: 100 }) .width('100%') .color(Color.Blue) } .layoutWeight(1) Button(item.btnText) .onClick(() => { // 处理点击 }) } .width('100%') .padding(12) .backgroundColor(Color.White) .borderRadius(12) } }, (item: ProjectModel) => item.id) } } }这段代码本身没什么问题,但同样的结构在首页、搜索页各复制了一份。等产品把标题换成了两行、按钮改为图标按钮时,我意识到重复代码的维护成本已经高于抽组件了。
两个方向:一个是抽自定义组件,另一个就是用@Builder。两者的取舍是理解本文的关键。
1.2 @Builder和自定义组件、普通函数的边界
先把概念说清楚。@Builder在鸿蒙ArkUI里叫自定义构建函数,它本质上是一个专门用来生成UI片段的方法。你写在@Builder里的不是渲染逻辑,而是UI结构描述,这些描述会被框架编译成对应的组件树。
它和自定义组件的本质区别在于:
- 自定义组件是一个类,有生命周期(
aboutToAppear、aboutToDisappear等)、有状态管理和自己的构建逻辑,它是组件树上的一个节点。 - @Builder只是一个方法,它没有实例、没有生命周期,它生成的UI会被"拼到"调用它的那个组件里,不会形成独立节点。
所以@Builder更像是一个"UI模板片段",适合用来组织组件内部的重复结构,或者在多个组件之间共享一段不复杂的UI。
和普通函数的区别就更好理解了:普通函数返回的是一个值,@Builder返回的(或者说"产出"的)是UI结构。你不能在普通函数里写Text()然后希望它渲染出来——那只是创建了一个对象而已。但在@Builder里,这些UI组件会被真正加入到渲染树中。
理解这个边界之后,"什么时候用@Builder"就清楚了:一段UI在单个组件里重复多次,或者要在几个组件间复用,但结构简单、不需要独立状态管理时,优先用@Builder。一旦这段UI需要有自己维护的状态、需要生命周期联动、需要被多个页面以不同逻辑复用,那就老老实实抽自定义组件。
2. 组件内@Builder的完整写法和它吃的"免费午餐"
2.1 成员函数的实现细节与可见性
先看组件内@Builder的标准写法。它实际上是@Component结构体下的一个成员方法,可以加private,也可以不加。默认就是私有,只能在当前组件内部使用。
@Component struct ProjectListPage { @State projects: ProjectModel[] = []; @State currentTab: number = 1; @Builder projectCard(item: ProjectModel) { Row({ space: 12 }) { Image(item.cover) .width(72) .height(72) .borderRadius(8) Column({ space: 4 }) { Text(item.title) .fontSize(16) .fontWeight(FontWeight.Medium) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Progress({ value: item.progress, total: 100 }) .width('100%') .color(Color.Blue) } .layoutWeight(1) Button(item.btnText) .onClick(() => this.handleCardClick(item)) } .width('100%') .padding(12) .backgroundColor(Color.White) .borderRadius(12) } build() { List({ space: 12 }) { ForEach(this.projects, (item: ProjectModel) => { ListItem() { this.projectCard(item) } }, (item: ProjectModel) => item.id) } } }这里有个细节值得注意:在onClick回调里我写了this.handleCardClick(item)。这个this指向的是当前组件实例,因为@Builder方法运行在组件的上下文环境中,所以可以直接访问组件里的所有属性和方法。这就是组件内@Builder的最大优势——它可以隐式访问当前组件的this环境,包括@State、@Prop、@Link、普通成员变量、甚至是路由对象。
这带来的好处很明显:你不需要把组件里的各种状态作为参数传进去,@Builder方法体里直接"看得到"组件的所有数据。很多情况下你甚至可以不用给@Builder传任何参数,它直接读this上的值就行了:
@Builder projectCardTitle() { Text(`${this.currentTab} / ${this.projects.length}`) }2.2 为什么它能直接访问this和@State
背后的原理其实不复杂。@Builder在组件内定义时,编译器会把方法绑定到这个组件实例上,因此方法体内出现的this就是组件实例本身。@State装饰的变量之所以能在@Builder里访问,并且后续状态变化能触发UI刷新,是因为@State的getter/setter机制和@Builder的渲染追踪是在同一个框架链路里的。
用生活化的方式理解:组件是一个房间,@State是房间里的一块白板,@Builder是房间里的一台电视。电视播放的内容可以直接读取白板上写的东西。白板内容变了,电视画面跟着刷新。整套联动都在房间内部完成,不需要从外面递纸条进来。
这一点在对比全局@Builder时会显得尤为关键,因为到了组件外面,"白板"就不存在了。
2.3 组件内@Builder的适用场景
从我的实践看,组件内@Builder最适合以下几种场景:
- 同一个组件里,多个地方使用相同的UI结构,比如列表的header和footer都有相同的标签样式。
- 这个UI片段依赖于组件内部大量状态,如果抽到全局,参数会传递得又臭又长。
- 你并不打算让别的组件复用这段UI,抽出去反而增加理解成本。
有个常见的错误倾向是:觉得@Builder很好用,于是把所有相似的UI全抽成全局@Builder。等到后期需求迭代时才发现,这个UI片段和某个页面的状态耦合得太深,传参已经传哭了。所以我的习惯是,先在组件内写,等确认要跨组件复用了再挪出去。
3. 把构建函数搬到组件外面:全局复用的代价与回报
3.1 抽成全局@Builder的第一步
当首页、分类页、搜索页都要用同一张项目卡片时,再让每个页面里各写一份组件内@Builder就没有意义了。这时需要把它定义在组件外面,变成全局构建函数。
@Builder export function projectCard(item: ProjectModel, btnText: string, onClick: () => void) { Row({ space: 12 }) { Image(item.cover) .width(72) .height(72) .borderRadius(8) Column({ space: 4 }) { Text(item.title) .fontSize(16) .fontWeight(FontWeight.Medium) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Progress({ value: item.progress, total: 100 }) .width('100%') .color(Color.Blue) } .layoutWeight(1) Button(btnText) .onClick(onClick) } .width('100%') .padding(12) .backgroundColor(Color.White) .borderRadius(12) }组件里调用:
@Entry @Component struct HomePage { @State projects: ProjectModel[] = []; build() { List({ space: 12 }) { ForEach(this.projects, (item: ProjectModel) => { ListItem() { projectCard(item, '查看详情', () => this.handleClick(item)) } }, (item: ProjectModel) => item.id) } } }和组件内版本对比,关键变化在于:全局@Builder函数访问不到任何this。它不在任何组件的上下文里,所以组件里所有的状态变量在全局函数内都是不可见的。想用数据,就必须通过参数传进来;想处理事件,就必须通过回调函数传进来。
这既是限制,也是约束的回报。全局@Builder是纯函数式的——输入参数,输出UI。没有隐式依赖,不读取全局上下文,这意味着它的可复用性和可预测性比组件内版本更强。
3.2 失去this之后,参数成了唯一的桥梁
刚才的例子已经体现了参数传递的核心思路:数据通过形参传入,交互通过回调形参传入。这里有一个非常重要的注意点——回调函数的this绑定。
看这一行:
projectCard(item, '查看详情', () => this.handleClick(item))回调写成了箭头函数,this指向HomePage组件实例。如果写成:
projectCard(item, '查看详情', this.handleClick)在鸿蒙上大概率会出问题。因为this.handleClick是一个普通成员方法,当你把它作为参数传出去再被调用时,它的this已经丢失了,运行时就会报"Cannot read properties of undefined"之类的错误。
全局@Builder还有一个容易被忽视的细节:它不能直接使用@BuilderParam,那是组件内和自定义组件之间做插槽用的。全局@Builder的复用方式就是纯参数,没有插槽的概念。想实现"卡片的尾巴可以自定义"这种需求,要么传给@Builder一个自定义组件的构造器,要么就放弃@Builder,改用自定义组件。
3.3 全局@Builder的适用场景与边界
适合用全局@Builder的场景其实比很多人想得更窄:
- 多个组件共享一段完全一样的UI片段。
- 该UI片段的输入和输出可以全部用参数表达,没有隐式状态依赖。
- 该UI片段结构简单,不涉及复杂动画、手势、状态管理。
一旦你的需求超出了这个边界,比如卡片内部自己维护了选中状态、比如点击卡片后要做复杂的入场动画、比如多个页面需要以完全不同的方式扩展卡片内容,@Builder就开始"变形"了。参数会越加越多,回调会越传越深,代码的可读性不升反降。这时候,抽自定义组件才是正解。
具体什么时候选哪个,我在第5章会给出一个比较完整的决策清单。
4. 状态刷新的分水岭:参数按值传递和传引用($$)的区别
4.1 默认按值传递的坑:改状态不刷新
这是@Builder使用中最容易踩的坑,也是组件内、组件外行为差异最明显的地方。
先看一个反例。假设我有一个全局@Builder,渲染一段文本:
@Builder export function tipsView(msg: string) { Text(msg) .fontSize(14) .fontColor(Color.Gray) }然后在一个页面的Button点击事件里修改了@State message:
@Entry @Component struct DemoPage { @State message: string = '初始文案'; build() { Column({ space: 12 }) { tipsView(this.message) Button('修改文案') .onClick(() => { this.message = '修改后的文案' }) } } }运行一下会发现什么?点击按钮,UI没有变化。这就是按值传递的机制:tipsView(this.message)在调用时,把this.message的值复制了一份传给了tipsView的形参msg。之后this.message变了,但msg还是旧值。
组件内@Builder遇到同样的写法也会这样吗?不一定。如果组件内@Builder里使用的是this.message,而不是形参,那状态变化会触发渲染更新,因为this.message是一个@State,它的getter被@Builder的渲染追踪机制标记了。但如果你在组件内@Builder里也是用形参来接收this.message,同样会被"按值传递"卡住。
所以,这个坑和组件内外无关,和参数传递方式有关。但实际项目中,全局@Builder几乎百分百会用到参数传递,而组件内@Builder往往直接读this,这就是为什么全局@Builder遇到这个坑的概率远高于组件内的。
4.2 用$$实现引用式传参
华为官方给了解决方案:按引用传递参数。写法是在@Builder函数的形参定义里,用$$包裹一个对象,这个对象里再声明你需要的参数:
@Builder export function tipsView($$: { msg: string }) { Text($$.msg) .fontSize(14) .fontColor(Color.Gray) }调用方式也要对应调整,不能直接传字符串,而是要传一个对象:
tipsView({ msg: this.message })这样传参时,this.message和$$.msg之间就建立了数据联动关系。当this.message变化时,@Builder里的$$.msg也会拿到新值,UI会随之刷新。
这个机制的原理,官方文档的说法是"传递的是引用"。在鸿蒙的渲染框架层面,$$包装符让编译器不再简单复制值,而是追踪这个属性与源状态变量之间的依赖关系。当源变量变更时,框架会把新的值同步给使用了$$的@Builder,并触发重新渲染。
对比一下两种方式的差异:
| 对比维度 | 按值传递 | 按引用传递($$) |
|---|---|---|
| 传参写法 | tipsView(this.message) | tipsView({ msg: this.message }) |
| 形参写法 | msg: string | $$: { msg: string } |
| 状态变化刷新 | 不刷新 | 刷新 |
| 使用复杂度 | 低 | 中 |
| 适用场景 | 静态展示、一次性渲染 | 需要跟随状态实时更新的UI |
4.3 组件内和组件外在这种场景下的真实差异
踩过上面的坑之后,我重新梳理了组件内和组件外@Builder在处理状态刷新时的差异,最后总结成一句话:组件内@Builder有两张牌可以打,全局@Builder只有一张。
组件内@Builder的第一张牌是直接访问this.xxx状态,这样天然能做到"状态变了UI就变";第二张牌才是$$传参。全局@Builder没有this可用,只能靠参数传值,所以全局版本只能靠$$这一张牌来建立数据联动。
来个对比场景:
// 组件内版本:直接使用this,状态变了自动刷新 @Component struct DemoPage { @State count: number = 0; @Builder countView() { Text(`数量:${this.count}`) } build() { Column({ space: 12 }) { this.countView() Button('加一').onClick(() => this.count++) } } }// 全局版本:必须传$$才能联动 @Builder export function countView($$: { count: number }) { Text(`数量:${$$.count}`) } @Component struct DemoPage { @State count: number = 0; build() { Column({ space: 12 }) { countView({ count: this.count }) Button('加一').onClick(() => this.count++) } } }这两种写法都能实现点击按钮后文本更新。但如果你在全局版本里粗心写成了countView(this.count),那就是按值传递,UI永远不更新,而且编译期不一定报错,运行时在真机上往往要盯半天才发现问题。
这里补充一个排查经验:如果某个用@Builder渲染的文本或组件在状态变化后不刷新,第一步就要去检查它的参数传递方式。先看@Builder的形参是不是用了$$,再看调用处是不是传了对象而不是裸值。两个条件缺一个,就不会刷新。
5. 实际项目中我的选型习惯与踩坑记录
5.1 什么时候坚持抽成自定义组件
@Builder确实轻量,但轻量意味着功能边界也小。我给自己定了一个选型标准,项目里遇到UI复用问题时按照下面这个顺序来判断:
- 这段UI只是结构重复、数据展示,没有自己独立的状态,也没有复杂的交互,那就用
@Builder。优先组件内,确认要跨组件了再挪到全局。 - 这段UI内部有状态需要维护,比如选中态、展开态,那就直接用自定义组件。不要试图通过
@Builder加一堆参数和回调来模拟状态管理,后期会很难受。 - 这段UI要在不同页面展示不同内容,但骨架相同,属于"同一个模板不同数据",用
@Builder传数据是非常合适的。 - 这段UI要做精细的动画和手势联动,比如拖动、缩放、自定义转场,用自定义组件。
@Builder没有生命周期监听,很多时候你连触发动画的时机都拿不准。 - 需要把一段UI作为插槽放到子组件的指定位置,用
@BuilderParam。这是在自定义组件内部定义插槽标准做法,不要试图用全局@Builder去模拟插槽。
5.2 几个容易误导人的细节
除了参数传递方式的坑,还有几个细节我经常在代码评审里看到,在这里一并列出。
第一个是不要在@Builder方法体里定义@State。我看到过有人这么写:
@Builder card(item: ProjectModel) { @State isLiked: boolean = false; // 错误写法 // ... }编译直接报错。@State只能用在@Component结构体的顶层,@Builder方法体内只能写UI描述语句,不能声明状态变量。如果你发现一段UI必须要一个局部状态,那说明它应该抽成自定义组件。
第二个是**@Builder方法体内可以有条件判断和循环**。你可以正常使用if/else、ForEach,这并不冲突。这非常适合做多态展示:
@Builder statusBadge(status: string) { if (status === 'success') { Text('已完成').fontColor(Color.Green) } else if (status === 'error') { Text('已失败').fontColor(Color.Red) } else { Text('进行中').fontColor(Color.Orange) } }第三个是**@Builder和@BuilderParam的配合**。这是在自定义组件里定义插槽的机制。父组件可以传一个@Builder方法给子组件,子组件通过@BuilderParam接收并渲染。这个功能很长一段时间我都没用上,直到做详情页需要向弹窗组件里注入自定义按钮时才体会到它的便利。但它和"组件内/外@Builder的区别"是两个维度的问题,不要混淆。
5.3 当UI没更新时,我的完整排查链路
最后记录一个排查链路。如果你在使用@Builder时遇到UI不更新的情况,按下面的顺序检查,基本覆盖80%的原因:
- 确认状态变量是不是
@State(或@Prop、@Link等)装饰的。普通成员变量在状态变化时本来就不会触发UI刷新,和@Builder无关。 - 确认数据是否从
@Builder的形参传入了。如果@Builder内部是读this.xxx,要看这个@Builder是不是组件内的。如果它是全局的,读到的this根本不存在,运行时会直接报错。 - 确认传递方式。形参如果是
$$: { xxx: type },调用处必须传对象。形参如果是裸类型,那状态变化不触发刷新是预期行为,你需要把它改成$$形式。 - 排除闭包捕获问题。在
ForEach的循环里调用@Builder,要小心循环变量被闭包捕获的问题。ForEach的第三个参数keyGenerator一定要正确,否则复用了旧组件实例,可能导致状态明明变了但渲染层复用老节点。 - 确认是否在
if/else分支中。如果@Builder渲染的组件不在当前分支中,当然不会显示。看着像"没更新",其实是"被隐藏了"。
有一次我排查一个"列表删除后总数不刷新"的问题,步骤1、2、3全查完了也没发现问题。最后发现是ForEach的keyGenerator写错了,每项的key里有重复值,导致框架复用了错误的子组件节点,UI重用了旧状态。那是另一个维度的坑了,但排查的时候很容易和@Builder的参数问题混淆。
5.4 我最终的项目结构建议
经过了几个项目的实践,我现在更倾向于**"组件内多用、全局慎用、复杂就抽组件"**的策略。
组件内的@Builder我几乎每个页面都会用。比如列表页里的header、footer、空态、Loading态,这些结构简单、和本页状态强依赖的UI片段,用组件内@Builder组织起来,代码会非常清爽。它们不需要跨页面复用,抽出去反而降低内聚性。
全局@Builder我只用在确认有两处以上需要复用的静态结构上。数量不会太多,因为抽成全局意味着必须把所有的状态依赖都通过参数暴露出来,如果参数超过三四个,我就开始重新考虑是不是用自定义组件了。
自定义组件则用来承载有状态、有生命周期、多页面以不同逻辑复用的UI。虽然样板代码多一点,但它提供了状态隔离和生命周期管理,这是@Builder给不了的。很多时候"重"一点反而是稳一点的保证。
结束语
回到开头那个项目卡片的问题。我最后是怎么处理的?卡片本身用全局@Builder抽了出来,因为三个页面的卡片高度一致,数据全部可以通过参数传进去。但卡片上的"进度条动画"后来变成了一个需要内部定时器的状态组件,那部分被单独抽成了一个ProgressCard自定义组件,由@Builder负责整体布局,ProgressCard负责动画逻辑。两者配合,代码结构清晰,后续需求迭代也没有再大改。
鸿蒙的@Builder是个很灵活的工具,难点不在于会不会写,而在于能不能想清楚"这段UI该放在哪"和"它和状态之间的联动关系是什么"。把我上面的排查链路和选型标准记下来,遇到实际问题时会少走不少弯路。