写这篇笔记之前,先提醒一句:状态管理V2的内容在鸿蒙开发里属于“越早搞懂越省心”的类型。上一篇笔记我整理了 V2 的整体设计和 @Local、@Param 的用法,这一篇专门聊两个容易被忽略、但实际开发里很好用的装饰器——@Once和@Event。
如果你已经写过一阵子 V1 的状态管理(@State 配 @Prop、@Link 那套),再看 V2 会有种“终于有人讲道理了”的感觉。@Once 解决的是“初始化传一次,之后互不干扰”的问题,@Event 解决的是“子组件怎么优雅地告诉父组件发生了什么事”的问题。这两个组合在一起,基本能把页面上 80% 的父子通信场景都覆盖掉。适合正在从 V1 迁移到 V2、或者刚开始学 V2 的同学,看完能直接往项目里用。
1. 先说背景:状态管理V2到底改了啥
1.1 V1时代的痛点
老版本的状态管理里,最常用的是 @State 管本地状态、@Prop 接收父组件传入、@Link 做双向同步。用起来确实直接,但用久了会踩到几个挺难受的坑:
第一个坑是@Prop 的“全量同步”。只要父组件里那个传进去的变量变了,子组件里对应的 @Prop 就会跟着更新。初看没问题,但很多时候父组件的重新渲染并不代表子组件关心的数据真的变了。比如父组件有个计数器的值一直在变,每次变化都会触发父组件重新 build,子组件被整个重建或刷新一遍。子组件做的事情其实很简单,只是展示一个固定的商品名,结果也被拖着一起刷新。性能倒是小事,关键是状态被意外覆盖:子组件内部想维护一点本地状态,结果父组件一更新,本地状态就被重置了。
第二个坑是@Link 的双向绑定太“自由”。子组件改一下,父组件变;父组件变,子组件又变。链条一长,你根本说不清楚是哪里改的。排查问题的时候只能顺着调用栈一层层翻,很痛苦。
第三个坑是事件通信基本靠函数属性。子组件想通知父组件,常规做法就是传一个回调函数进去。但底层的刷新机制对这个回调并不友好,函数传进去之后,触发时机、this 指向、默认值各种细节全靠开发者自己注意,框架层面的约束很少。
V2 的出现,就是针对这些坑逐个下药。
1.2 V2的核心思路:把“自动同步”变成“按需流动”
V2 的设计理念,总结成一句话就是:数据流是单向的,谁的数据谁修改,谁的事件谁来发。
在这个理念下,V2 把装饰器分成了几类:
- @Local:组件本地状态,替代 V1 的 @State
- @Param:父组件向子组件传入的数据,替代 V1 的 @Prop 的一部分职责
- @Once:@Param 的补充标记,让传入的数据只在初始化时生效一次
- @Event:子组件向父组件抛出的事件回调,替代 V1 里“手写函数属性”的做法
- @Provider / @Consumer:跨层级共享,替代 @Provide / @Consume
- @ObservedV2 / @Trace:细粒度观察嵌套对象变化
这个分层很有意思,它把“数据”和“行为”彻底拆开了。数据用 @Param 往下传,行为用 @Event 往上抛,组件之间不再“互相绑定”,而是“各管一段”。对调试特别友好:数据流是单向的,你一眼就能看出某个值是从哪来的,某个动作是在哪触发的。
用生活里的例子来类比:旧版状态管理像两个人共用一个记事本,谁都能改,改完互相覆盖;V2 更像公司里的汇报关系,上级往下派任务(@Param),下级做完向上汇报(@Event),各司其职,出了事也找得到人。
理解了这套设计,再去看 @Once 和 @Event,思路就顺多了。
2. @Once:一次绑定,之后各管各
2.1 @Once 的语义与定位
@Once 不是独立使用的装饰器,它是 @Param 的“辅助标记”,作用就一句话:让被修饰的 @Param 变量只在组件首次初始化时接收父组件的传值,之后父组件哪怕反复更新这个值,也不会再同步到子组件。
为什么要这个东西?我们回到实际场景里看看。
父组件经常会有一些变化频繁的状态,比如购物车总件数、页面滚动偏移、搜索框的输入内容等等。这些状态一变,父组件就得重新 build。于是问题来了:父组件每次 build,都会给子组件重新传一遍参数。
对于子组件来说,有些参数它其实只关心“第一次拿到的是什么”。比如商品卡片里的商品名称、单价、图片地址;比如详情页里某个只初始化一次的外部配置。这些信息本质上不会变,但父组件每次刷新都会带着它们跑一趟。用 @Once 标记之后,子组件只在装帧时接收一次,后续父组件再刷新,就与它无关了。
这等于是在“父组件频繁刷新”和“子组件不想被频繁刷新”之间,加了一道防火墙。
不过要留意,@Once 不是完全不刷新子组件。如果子组件里有 @Local 状态在变,它该刷新还是会刷新;如果 @Param 里还有其他没加 @Once 的字段变了,同步也仍然会发生。@Once 只针对它修饰的那一个字段做“切断后续同步”。
2.2 实操示例:购物清单的商品卡片
我写了一个很典型的例子,用商品卡片组件来展示 @Once 的用法。
先看子组件:
@ComponentV2 struct GoodsCard { // 商品基本信息:只在首次初始化的时点传一次,之后不再被父组件覆盖 @Param @Once name: string = ''; @Param @Once price: number = 0; // 本地可选择的数量 @Local count: number = 1; build() { Column({ space: 8 }) { Text(this.name).fontSize(20).fontWeight(FontWeight.Bold) Text(`单价:¥${this.price}`).fontSize(16) Row({ space: 12 }) { Button('减') .onClick(() => { if (this.count > 1) { this.count--; } }) Text(`${this.count}`).fontSize(18) Button('加') .onClick(() => { this.count++; }) } } .padding(16) .backgroundColor('#FFFFFF') .borderRadius(12) } }再看父组件如何传入:
@Entry @ComponentV2 struct ShoppingListPage { @Local refreshFlag: number = 0; build() { Column({ space: 16 }) { Text(`触发父组件刷新的标记:${this.refreshFlag}`) Button('点击触发父组件刷新') .onClick(() => { this.refreshFlag++; }) GoodsCard({ name: '机械键盘', price: 399 }) } .padding(20) } }注意一下:父组件我设了一个refreshFlag,点一次按钮它就会 +1,父组件整个 build 会重新执行。如果没有 @Once,GoodsCard每次都会收到一份新的name和price,组件可能会因此重新渲染。加了 @Once 之后,卡片里的商品名和单价就成了“一次性设定”,父组件刷新多少次都与它无关。
2.3 和 V1 的对比:为什么不是 @Prop
有人可能会想:V1 里不是有 @Prop 吗?直接拿来用不就好了?
这里面的差异很关键,我整理了一下:
| 对比项 | V1 @Prop | V2 @Param | V2 @Param + @Once |
|---|---|---|---|
| 初始化传值 | 支持 | 支持 | 支持 |
| 父组件后续更新 | 自动同步到子组件 | 自动同步到子组件 | 不同步 |
| 子组件内修改 | 不允许直接赋值 | 不允许直接赋值 | 允许自行管理 |
| 适用场景 | 父组件数据频繁变化且需要子组件联动 | 大部分父子传参 | 仅初始化时需要,之后保持独立 |
尤其要记住最后一行:@Once 修饰的变量,初始化后子组件内部是可以自己改的,改完也不会反馈到父组件。因为父子之间的同步通道已经被切断了,它在语义上更像是“从父组件那里领了一份初始值,之后就是子组件自己的东西”。
2.4 用 @Once 的三个注意点
第一个注意点:默认值必须写。@Param 和 @Once 装饰的变量一定要给默认值,否则在父组件还没传值、或传值顺序异常的时候,子组件 build 阶段可能读到一个 undefined,直接白屏或者报错。习惯上给个空字符串、0、或者一个最小对象,都能保证组件在极端情况下也有能力渲染。
第二个注意点:不要给对象加 @Once 就以为万事大吉。@Once 只是切断了“后续参数同步”,但对于对象内部属性的变化,它的保护是有限的。如果父组件传入的是一个对象,而你在子组件里修改了这个对象的某个属性,这种修改依赖 Object 类型的传参机制。对于嵌套层级比较深的对象,官方推荐的做法是配合 @ObservedV2 和 @Trace 使用。如果不加这些,深层属性变化可能不会被 UI 感知,不是 @Once 的问题,而是整个响应式体系的边界。
第三个注意点:@Once 不是用来“优化性能”的银弹。它的真正价值在于语义清晰:哪些数据只在初始化时被关心,一眼就能看出来。不要为了减少一点刷新就把所有 @Param 都加上 @Once。父组件后续更新需要被子组件感知的字段,请保持普通 @Param 的默认行为。
3. @Event:让子组件把“事情”交还给父组件
3.1 为什么需要 @Event
讲完数据向下,再讲事件向上。
在一个正经的鸿蒙项目里,子组件很少只是显示出数据就完事了,更多时候它要做一些操作,然后把这些操作的结果告诉父组件。比如商品卡片的“加入购物车”、输入框内容的“实时上报”、列表项的“点击跳转”。
V1 时代的做法很朴素:直接在父组件里传一个函数属性给子组件:
// V1 老写法 GoodsCard({ name: '机械键盘', price: 399, onAddToCart: (count: number) => { // 父组件处理逻辑 } })子组件内部调用this.onAddToCart?.(count)就行。这个写法能跑,但有两个小毛病:
- 函数属性没有统一的声明约束,它可能是箭头函数、普通函数、甚至没传,子组件只能靠
?.兜底,很容易在某个分支忘记判空就调用。 - 函数属性和数据属性混在一起,看组件的 “props 列表”时,根本分不清哪些是数据、哪些是行为,维护成本高。
@Event 就是来解决这两个问题的。它就是 V2 体系里“事件”的正式公民。
3.2 @Event 的定义与调用
@Event 装饰的变量必须是函数类型,使用上分两步:
第一步在子组件里声明:
@ComponentV2 struct ChildComponent { // 声明一个事件,默认给一个空实现 @Event onCountChange: (value: number) => void = () => {}; build() { Button('点击') .onClick(() => { // 触发事件,把值带出去 this.onCountChange(42); }) } }第二步在父组件里传入实现:
// 父组件 @Local count: number = 0; build() { Column() { ChildComponent({ onCountChange: (value: number) => { this.count = value; }}) } }整个过程就是一个非常明确的“上报”流程:子组件不关心父组件收到之后会干什么,它只负责把“事情发生了”这个消息 + 相关参数抛出去。父组件也不关心子组件内部的状态细节,它只负责在收到消息时做该做的事。
我一开始不太理解为啥 @Event 要有个默认空实现,后来在真机上跑了一遍就懂了:如果没有空实现,当父组件还没传进来、或者某些条件分支漏传了,子组件调用this.onCountChange(...)就会直接崩掉。有了默认空实现,漏传的情况下调用也不会报错,最多就是“没人响应”。这对框架的健壮性是很实用的设计。
3.3 典型场景:商品卡片的加入购物车
还是拿刚才的 GoodsCard 来扩展。现在商品卡片上多了一个“加入购物车”按钮,点击之后要把当前选中的数量告诉父组件,让父组件去更新购物车总件数。
子组件改造如下:
@ComponentV2 struct GoodsCard { @Param @Once name: string = ''; @Param @Once price: number = 0; @Local count: number = 1; // 上报加购事件,参数是当前数量 @Event onAddToCart: (count: number, name: string) => void = () => {}; build() { Column({ space: 12 }) { Text(this.name).fontSize(20).fontWeight(FontWeight.Bold) Text(`单价:¥${this.price}`).fontSize(16) Row({ space: 12 }) { Button('减').onClick(() => { if (this.count > 1) { this.count--; } }) Text(`${this.count}`).fontSize(18) Button('加').onClick(() => { this.count++; }) } Button('加入购物车') .onClick(() => { this.onAddToCart(this.count, this.name); }) .width('100%') } .padding(16) .backgroundColor('#FFFFFF') .borderRadius(12) } }父组件这边接收事件,更新自己的状态:
@Entry @ComponentV2 struct ShoppingListPage { @Local totalCount: number = 0; build() { Column({ space: 16 }) { Text(`购物车总件数:${this.totalCount}`) .fontSize(24) .fontWeight(FontWeight.Bold) GoodsCard({ name: '机械键盘', price: 399, onAddToCart: (count: number, name: string) => { this.totalCount += count; // 这里甚至可以继续上报全局埋点 } }) } .padding(20) } }“加入购物车”这个动作,数据流的走向非常清晰:
- 子组件里数量是 @Local 的,自己管理加减
- 点击按钮通过 @Event 把
count和商品名抛出去 - 父组件收到后在回调里更新
totalCount totalCount变化后,父组件重绘,新的总数显示出来
上面例子在视觉上就是一个完整的闭环。
3.4 @Event 和 @Param 一起用的设计逻辑
实际项目里,@Event 跟 @Param 通常是成对出现的。@Param 往下传数据,@Event 往上抛事件,两者合起来就是一套标准的“受控组件”模式。
这个模式的关键点是:父组件是唯一的“数据源”。子组件要显示什么,由 @Param 决定;子组件要想改数据,不能自己直接改 @Param,而是通过 @Event 告诉父组件“该改了”,父组件改完之后,新的值又通过 @Param 传回来。
听起来绕,但这个设计能避开很多版本迭代时的坑:数据只在一个方向流动,父组件永远知道自己的数据是什么状态,不会出现“子组件偷偷改了值,父组件一脸懵”的情况。
这种方式跟 React 里的props + onChange是非常像的,如果你写过 React,理解 V2 这套几乎无成本。反过来,如果你一直写的是双向绑定风格,刚开始可能觉得繁琐,但跑几个复杂页面之后就会认同“显式比隐式好维护”这个道理。
一个实用建议:@Event 回调的参数尽量保持原子化,能传 count、id 这类基本类型,就别传一个整对象。回调参数越简单,父组件处理越灵活,代码也越好读。
4. 完整Demo:把 @Once 和 @Event 串起来
4.1 Demo 需求与分析
前面都是拆开讲,这节把两个装饰器放在同一个页面里跑一遍,看看它们在真实组合场景里的表现。
页面需求很简单:
- 页面顶部显示购物车总件数,作为父组件的顶级状态
- 中间是两张商品卡片,各自展示名称和价格
- 卡片内部可以加减数量,初始数量从外部传入一次
- 每张卡片有一个“加入购物车”按钮,点击后把数量上报父组件
- 父组件的“刷新标记”会不断变化,测试卡片是否被无谓刷新
设计核心在于:卡片的基础信息属于“一次性初始数据”,用 @Once;卡片的操作动作属于“向上事件”,用 @Event;卡片内部的数量属于“自身状态”,用 @Local。
4.2 代码实现
先写商品卡片子组件:
@ComponentV2 struct GoodsCard { // 静态数据:初始化时传入一次,之后不受父组件刷新影响 @Param @Once id: number = 0; @Param @Once name: string = ''; @Param @Once price: number = 0; @Param @Once maxCount: number = 20; // 本地数量 @Local count: number = 1; // 上报事件 @Event onAddToCart: (id: number, name: string, count: number) => void = () => {}; build() { Column({ space: 10 }) { Row() { Text(this.name).fontSize(18).fontWeight(FontWeight.Bold) Text(`¥${this.price}`).fontSize(16).fontColor('#E84026') }.width('100%').justifyContent(FlexAlign.SpaceBetween) Row({ space: 10 }) { Button('−') .onClick(() => { if (this.count > 1) { this.count--; } }) Text(`${this.count}`).fontSize(18).width(40).textAlign(TextAlign.Center) Button('+') .onClick(() => { if (this.count < this.maxCount) { this.count++; } }) } Button(`加入购物车(${this.count}件)`) .width('100%') .onClick(() => { this.onAddToCart(this.id, this.name, this.count); }) } .padding(16) .backgroundColor('#FFFFFF') .borderRadius(12) .shadow({ radius: 6, color: '#11000000', offsetY: 4 }) } }再写页面父组件:
@Entry @ComponentV2 struct ShoppingDemoPage { @Local totalCount: number = 0; @Local refreshFlag: number = 0; build() { Column({ space: 16 }) { Text(`购物车总件数:${this.totalCount}`) .fontSize(22) .fontWeight(FontWeight.Bold) Row() { Text(`父组件刷新标记:${this.refreshFlag}`) Button('触发刷新').onClick(() => { this.refreshFlag++; }) }.width('100%').justifyContent(FlexAlign.SpaceBetween) GoodsCard({ id: 1, name: '机械键盘', price: 399, maxCount: 10, onAddToCart: (id: number, name: string, count: number) => { this.totalCount += count; // 可在此追加日志 console.info(`AddToCart: id=${id}, name=${name}, count=${count}`); } }) GoodsCard({ id: 2, name: '显示器支架', price: 129, maxCount: 5, onAddToCart: (id: number, name: string, count: number) => { this.totalCount += count; // 可在此追加日志 console.info(`AddToCart: id=${id}, name=${name}, count=${count}`); } }) } .padding(20) .backgroundColor('#F1F3F5') .width('100%') .height('100%') } }4.3 运行效果与数据流解读
把页面跑起来,操作一下,你会很直观地感受到这套设计:
- 点击两三次“触发刷新”,父组件的
refreshFlag一直在涨,但商品卡片的名称、价格、数量都不会被重置。这就是 @Once 在起作用。 - 在一张卡片里把数量加到 3,点击“加入购物车”,父组件顶部的
totalCount立刻变成 3。这是 @Event 在起作用。 - 点另一张卡片加购 2 件,
totalCount变成 5,之前那张卡片内部的 count 还是 3,没有被父组件刷新干扰。
数据流全景是这样的:
父组件状态(totalCount) ↓ 通过 @Param/@Once 传入初始数据 子组件(GoodsCard) ↓ 通过 @Event 上报事件 父组件状态(totalCount)这个环是闭合的,但每一环都是单向的。排查问题的时候,你只要沿着这个方向去查就行,不需要双向追踪。
5. 常见问题与避坑记录
这一节写写我在实际开发里遇到过的坑,以及身边朋友踩过的雷。有些问题官方文档提过但不够醒目,我按真实场景整理了一下。
5.1 @Once 修饰的变量被覆盖,找不到原因
现象:明明在子组件里把某个 @Param @Once 的变量改了,父组件一刷新,值又变回去了。
排查方向:先确认父组件是否对整个子组件做了重建,而不是仅仅调整了属性。在某些写法下,如果父组件用if/else包裹子组件,条件翻转会导致子组件销毁重建,这时候 @Once 的“只初始化一次”就不是遮挡了,而是新组件重新初始化了一次。这个坑严格来说不是 @Once 的锅,而是组件生命周期的问题。
经验:遇到“变化被复位”的情况,第一反应不是查装饰器,而是查这个组件到底有没有被重建。在 build 里加一行日志,看打印次数就能确认。
5.2 @Event 回调不执行,但又不报错
现象:点击按钮,页面没反应,控制台也没错误。
大多数情况是默认空实现被调用了,也就是说父组件并没有把真正的函数传进来。常见原因有:
- 父组件里改名了回调参数,但传参时忘了同步
- 子组件在某个分支重新实例化了,父组件没感知到
- 在列表场景里,@Event 挂在 item 对象上,item 被复用或替换时回调没绑上
排查技巧:不要只在父组件的回调里打日志,在子组件触发前也打一行日志,确认子组件确实触发了this.onAddToCart(...)。如果子组件打了日志而父组件没有,问题就在传参链路上;如果子组件压根没打日志,先查按钮事件有没有绑定。
5.3 @Event 里使用普通函数,this 指向出错
父组件里传 @Event 时,写箭头函数是理智的做法。有人为了“整洁”写了普通函数,结果回调里访问this.totalCount时拿不到组件实例,直接报 undefined。
// 不推荐:普通函数里 this 指向会有问题 GoodsCard({ onAddToCart: function (id: number, count: number) { this.totalCount += count; // this 不是组件实例 } }) // 推荐:箭头函数 GoodsCard({ onAddToCart: (id: number, count: number) => { this.totalCount += count; } })这个其实不是 @Event 特有的问题,而是 JS 里 this 的经典坑。但因为 @Event 的赋值点通常在 build 里,写起来很容易顺手写个普通函数,所以单独拎出来提醒一下。
5.4 @Param 对象类型配合 @Once 时,属性变化无感
现象:父组件传了一个对象给子组件,对象里面某个字段在子组件内部被修改了,UI 没刷新。
原因:@Param 传对象时,默认是深拷贝一份给子组件,子组件改的是副本。而 UI 刷新依赖响应式系统对这个字段建立观察。没有 @ObservedV2 + @Trace 加持的对象,属性级变化是感知不到的。
经验:如果子组件需要修改对象内部字段且要让 UI 感知,不要靠 @Once 或普通 @Param 扛,该上 @ObservedV2/@Trace 就上。@Once 只负责“初始化后不同步”,不负责“深层属性响应式”。
5.5 问题速查表
| 症状 | 可能原因 | 处理方向 |
|---|---|---|
| @Once 变量值被重置 | 子组件被销毁重建 | 检查父组件 if/else 与 key 的变化 |
| @Event 无响应 | 父组件没传实现,空函数被调用 | 检查传参名、列表 item 复用 |
| @Event 回调里 this 异常 | 用了普通函数 | 改成箭头函数 |
| 对象属性改了 UI 不变 | 缺少 @ObservedV2/@Trace | 补充细粒度观察 |
| @Param 设置了默认值仍报错 | 默认值与传入类型不一致 | 保证同类型再传值 |
| V1 和 V2 装饰器混用 | 组件结构不一致 | 检查是否混用 @Component 和 @ComponentV2 |
关于第六条多说一句:V1 的 @State、@Prop 不能和 V2 的 @Param、@Event 混在同一个组件里。一个组件要么走 V1 装饰器体系,要么走 V2 装饰器体系,别为了省事临时混搭,编译期可能不报错,运行期行为会很诡异。
6. 我这几次实践下来的延伸体会
@Once 和 @Event 单独看都不复杂,组合起来却改变了我写组件的方式。
以前写子组件,脑子里想的都是“我这个组件需要哪些外部数据”。现在写子组件,会先问两个问题:这些数据是只初始化需要,还是后续父组件变我也要跟着变?组件内部发生的动作,是应该自己消化,还是抛给父组件处理?这两个问题问完,组件接口基本就定了。接口定得清晰,代码就不会长歪。
另外强烈建议在项目初期就把 V2 这套统一起来用,不要新旧掺杂。V1 和 V2 虽然在同一个工程里能共存,但混用久了,新人看到概念各不相同、逻辑时隐时现的代码,基本就放弃维护了。如果团队正在从 V1 迁 V2,拿状态管理这块开刀是最值得的,因为它的收益最明显:数据流方向清楚、问题可追踪、组件边界清晰。
最后分享一个小技巧:调试 @Event 链路的时候,可以在子组件的 @Event 声明处加一个默认实现来打日志,比如:
@Event onAddToCart: (id: number, count: number) => void = (id: number, count: number) => { console.info(`[sub] onAddToCart called, id=${id}, count=${count}`); };这样即使父组件漏传了实现,子组件触发时也能留下日志,排查效率直线提升。等确认链路没问题了,再把这个默认实现清空也不迟。