☰
鸿蒙状态管理V2核心:@Once与@Event装饰器设计思想与实战
2026/10/2 15:14:16 网站建设 项目流程

最近在过鸿蒙状态管理V2这部分内容,@Once和@Event是我印象最深的两个装饰器。不是因为它俩多难写,而是它俩背后的设计思路,和V1那一套“所有变量都能改”的玩法完全不一样。课程笔记已经整理到第二篇,上一篇是@Local和@Param的用法,这一篇专门把@Once、@Event一次讲透。如果你也在学HarmonyOS的ArkUI状态管理,或者正准备把老代码从V1往V2迁,这篇应该能帮你少走不少弯路。

1. V1的状态管理到底卡在哪里,V2又是怎么破局的

1.1 V1装饰器体系的三个老大难

先说个背景:V1时代的状态管理,核心是@State、@Prop、@Link这三件套。刚上手时用着挺顺手,组件内部放个@State变量,改一改UI就刷新;跨组件传值用@Prop接一下,@Link还能双向同步。但项目一旦大起来,问题就全冒出来了。

第一个问题是“谁都能改”。@State变量在组件内部随便赋值,跨组件时@Link又会把父组件的变量和子组件绑在一起。业务复杂以后,你很难回答一个最基本的问题:屏幕上这个数字,到底是被哪段代码改掉的?我见过不止一次,线上反馈某个页面数据对不上,排查的时候满工程搜this.xxx =,找出来七八处赋值点,每处都看起来“合理”。这种追踪成本在V1体系下几乎无解。

第二个问题是“双向绑定太方便,反而坏了事”。@Link用起来是真的舒服,子组件里改一下,父组件跟着变。但舒服是舒服,数据流却被搅成一锅粥。UI的显示状态、业务数据、临时交互状态全部混在一起,你根本分不清哪个状态是“源头”,哪个状态只是“投影”。一旦页面交互复杂,比如同一个数据被两个子组件同时改,最后的写入者赢,留给你的是一个很难复现的bug。

第三个问题是跨层传递的侵入性。老页面结构深一点,父组件要传一个参数给孙组件,中间那个子组件哪怕完全不关心这个数据,也得声明一个@Prop帮它往下传。传参数传得满屏都是,组件本身的职责边界全被冲散。

1.2 V2的设计主线:把“可观察”和“可修改”分开

V2这套新装饰器,本质上是换了一套思考方式:不再问“哪个变量可以被观察”,而是问“这个状态的修改权到底归谁”。

我总结下来,V2把状态分成了四类:

  • @Local:组件自己持有、自己修改的本地状态,对标V1的@State。
  • @Param:从父组件传下来的参数,父组件更新时会同步到子组件,子组件把它当“输入”看待。
  • @Param + @Once:同样是父组件传参,但只认初始化那一次,创建完成后就不再接受更新。
  • @Event:父组件传下来的是一个函数,子组件不能修改它,只能调用它,用来向父组件“报告发生了什么事”。

把这四类摆在一起,你就能看出来V2的思路:数据归数据,事件归事件,谁拥有状态、谁只能读、谁负责改,全部在组件声明阶段写清楚。

这里放一张对照表,便于从V1的习惯里切换过来:

用途V1V2
组件内部可变状态@State@Local
父传子参数,后续会更新@Prop@Param
父传子参数,只管初始值手工赋值绕来绕去@Param + @Once
子组件反向影响父组件@Link 或 回调函数@Event
计算派生状态手工计算或@Watch里改@Computed

1.3 先给@Once和@Event一个准确定位

单看语法,@Once和@Event都简单到不行:一个限制“只能初始化一次”,一个限制“只能调用不能改”。但真正理解它们,要放到V2的整体设计里看。

@Once解决的是“静态配置”和“动态数据”混为一谈的问题。以前的组件接收到一个参数后,内部代码往往不区分“这个参数创建后就不再变”和“这个参数后续还会被父组件更新”,于是每个参数都得按可更新的逻辑去处理,白白增加刷新开销和出错面。

@Event解决的则是“子组件如何跟父组件沟通”的问题。V1时代,这个靠传回调函数或者@Link双向绑定实现,但回调函数没有类型约束,@Link又容易把数据流搅乱。@Event把“上报动作”变成一种正式的、受框架约束的状态变量,让父子通信有了统一的语法和检查机制。

想通了这两点,再看后面的用法和案例会顺很多。

2. @Once:把“初始化之后不可变”写进组件契约

2.1 一句话理解@Once的语义

@Once单独出现的时候其实很少,它通常是配合@Param一起用的,写法是@Param @Once。语义一句话就能说清:这个变量的值,只在组件创建的时候由外部传入或本地初始化确定一次,之后整个生命周期内不能再被赋值。

注意,它和const不一样。const是编译期的“不可变引用”,@Once是组件生命周期层面的“初始化冻结”。被@Once修饰的变量,组件创建完那一刻起就是只读的,你再给它赋值,框架会直接拦截并报错。

看一段最简单的代码:

@ComponentV2 struct ConfigCard { @Param @Once title: string = ''; @Param @Once showFooter: boolean = true; build() { Column() { Text(this.title) if (this.showFooter) { Text('页脚信息') } } } }

父组件创建它的时候传一次值:

ConfigCard({ title: '订单详情', showFooter: true })

如果后面父组件里title变了,ConfigCard里的title也不会跟着变。这就是@Once最核心的行为。

2.2 @Once能出现在哪些场景里

搞清楚了语义,自然就知道什么场景该用它。我实际写下来,遇到最多的是三类。

第一类是静态配置项。比如组件支持showFooter、maxLines、themeColor这类参数,它们描述的是“这个组件长什么样”,而不是“这个组件当前处于什么状态”。组件一旦建好,这些配置就不该变。

第二类是路由跳转带过来的初始参数。页面从详情入口进来,拿到一个商品ID或者订单号,整个页面生命周期内这个ID不会变。把它定义成@Param @Once,既明确语义,也防止后续哪里手滑改掉它。

第三类是创建时的数据快照。我后面完整案例里会写一个”初始化统计“组件,它只需要展示“进入页面那一刻的商品数量”,页面后续数据再怎么变化,这行统计都保持原样。这种场景用@Once再合适不过。

反过来说,如果一个参数在页面运行期间需要跟随外部变化而更新,那它就不该用@Once,要用@Param。

2.3 @Once和@Param、@Local的选型对比

新手最容易犯的错,是拿到一个“父组件传进来的值”就无脑用@Param。实际上,你该先问自己一句:这个值创建之后还要不要变?

我整理了一个选型思路:

场景推荐写法理由
组件内部自己的开关、计数、临时选中态@Local只在组件内使用,不需要外部介入
父组件控制的筛选条件、列表数据、统计数@Param父组件更新时要能传导进来,驱动UI刷新
页面基础配置、路由参数、创建时快照@Param + @Once值在创建时定死,减少刷新,语义清晰
子组件需要向父组件发送操作请求@Event函数类型,只能调用不能改,走事件通道

一句话总结:拿不准的时候先用@Param,跑通了再想“这里能不能用@Once收紧”。反过来用,坑会更多。

3. @Event:把“子组件通知父组件”变成框架级的一等公民

3.1 @Event到底是什么

先打一个比方。父组件往子组件手里塞了一个“门铃按钮”,并且告诉子组件:“这个按钮你拿着,想让我做事就按一下,按完我自己知道怎么办。”子组件能做的只是按门铃,它不能拆开按钮改里面的逻辑,也不需要关心父组件听到门铃后是去开门还是去倒水。

@Event就是这么个门铃按钮。它的类型必须是函数类型,由父组件在创建子组件的时候传进来。子组件内部不能给这个变量赋新值,只能调用它。

这样做有什么好处?非常直白的一点:子组件对外暴露的能力变成了“可检查的契约”。以前你想知道一个组件能对外触发什么动作,得翻源码找它的回调参数;现在看组件定义的@Event变量就够了,一个组件对外有哪几个事件出口,一眼扫完。

3.2 事件定义、默认值和类型约束

@Event的定义语法很简单:

@ComponentV2 struct NavButton { @Event onNavigate: (target: string) => void = (target: string) => {}; build() { Button('跳转') .onClick(() => { this.onNavigate('/detail'); }) } }

这里有两个细节值得单独说。

第一个细节是默认值一定要给。不建议省略,更别指望父组件“一定会传”。给一个空函数当默认值,好处是组件单独开发调试、或者某个页面忘了传事件时,组件不会因为调用了undefined崩溃,只会安静地什么都不做。我见过有人图省事不写默认值,结果在预览器里一点按钮直接报错,排查半天发现是事件没传进来。

第二个细节是建议给事件类型起别名。页面复杂了以后,同一个类型的事件回调会在多个组件里出现,直接写函数类型容易有一处手滑写错。定义别名能统一约束:

type CategoryHandler = (category: string) => void; @ComponentV2 struct FilterBar { @Event onCategoryChange: CategoryHandler = (category: string) => {}; }

这样一来,组件对外暴露的事件签名就固化了,传参、调用都不容易错。

3.3 @Event的触发与传参

调用@Event变量,和调用普通函数没有任何区别。需要传参就传参,参数个数和类型在定义时已经定死:

this.onCategoryChange('digital'); this.onReset();

有一点要注意:@Event适合做“上报动作”,不适合在组件内部直接塞一堆业务逻辑。父组件传进来的回调,应该只表达“发生了什么事”,至于父组件收到事件后怎么更新状态、要不要发请求、要不要弹窗,那是父组件自己的事。这样拆分后,子组件保持纯粹,父组件保留决策权,调试的时候也容易定位。

4. 一个页面把三者串起来:筛选列表的完整实现

4.1 页面结构和数据流设计

讲完了单独的用法,我们来看一个综合场景:商品列表页,上面有分类筛选按钮,下面有商品列表,顶部还有一个“初始化统计”的标题栏。

这个页面的状态归属我这样设计:

  • 当前选中的分类category,归父组件GoodsPage持有,用@Local管理。为什么?因为切换分类是父组件内部的行为,同时会影响多个子组件(FilterBar的高亮态、列表的内容),状态放父组件最合理。
  • 商品列表goodsItems,同样归父组件持有。它本质上是一份业务数据,子组件只是展示它,没有改它的权利。
  • 筛选后的列表filteredItems,不单独存一份新数组,用@Computed派生。父组件的category一变,它自动重新计算。
  • FilterBar需要的“当前分类”,用@Param传进去,这样父组件更新分类后,FilterBar的按钮高亮能跟着动。
  • FilterBar要通知父组件“用户点了哪个分类”“用户点了重置”,用@Event上报。
  • StatsTitle需要展示“初始化时的商品总数”和一个来源标记,创建后就不再变化,用@Param + @Once接收。

这样整个页面的数据流是单向的:父组件持有状态,通过@Param往下发;子组件产生交互,通过@Event往上报;父组件改完状态,再通过@Param把新值同步回子组件。没有模糊地带。

4.2 完整代码

先定义商品数据类:

@ObservedV2 class GoodsItem { @Trace name: string = ''; @Trace price: number = 0; @Trace category: string = ''; constructor(name: string, price: number, category: string) { this.name = name; this.price = price; this.category = category; } }

然后写父组件:

@Entry @ComponentV2 struct GoodsPage { @Local category: string = 'all'; @Local goodsItems: Array<GoodsItem> = [ new GoodsItem('手机', 3999, 'digital'), new GoodsItem('笔记本电脑', 6999, 'digital'), new GoodsItem('冰箱', 2999, 'home'), new GoodsItem('洗衣机', 2499, 'home'), new GoodsItem('空调', 3299, 'home') ]; @Computed get filteredItems(): Array<GoodsItem> { if (this.category === 'all') { return this.goodsItems; } return this.goodsItems.filter(item => item.category === this.category); } build() { Column({ space: 12 }) { Text(`当前分类:${this.category}`) StatsTitle({ initCount: this.goodsItems.length, source: 'GoodsPage' }) FilterBar({ category: this.category, onCategoryChange: (category: string): void => { this.category = category; }, onReset: (): void => { this.category = 'all'; } }) ForEach(this.filteredItems, (item: GoodsItem) => { Row() { Text(item.name) Text(`价格:${item.price}`) } }, (item: GoodsItem) => item.name) } } }

再写FilterBar子组件:

@ComponentV2 struct FilterBar { @Param category: string = 'all'; @Event onCategoryChange: (category: string) => void = (category: string) => {}; @Event onReset: () => void = () => {}; build() { Column({ space: 8 }) { Row({ space: 4 }) { Button('全部') .backgroundColor(this.category === 'all' ? '#007dff' : '#cccccc') .onClick(() => this.onCategoryChange('all')) Button('数码') .backgroundColor(this.category === 'digital' ? '#007dff' : '#cccccc') .onClick(() => this.onCategoryChange('digital')) Button('家电') .backgroundColor(this.category === 'home' ? '#007dff' : '#cccccc') .onClick(() => this.onCategoryChange('home')) } Button('重置筛选') .onClick(() => this.onReset()) } } }

最后是使用@Once的StatsTitle子组件:

@ComponentV2 struct StatsTitle { @Param @Once initCount: number = 0; @Param @Once source: string = ''; build() { Row() { Text(`${this.source} 初始化时商品总数:${this.initCount}`) } } }

4.3 时序分析:用户点击按钮后发生了什么

代码写完了,重点看一遍交互发生的完整链路。以用户点击“数码”按钮为例:

  1. FilterBar内部按钮的onClick触发,调用this.onCategoryChange('digital')。
  2. 这个函数不是FilterBar自己的逻辑,而是父组件在创建FilterBar时通过@Event传进去的那个箭头函数,里面执行的是this.category = category。
  3. 父组件GoodsPage的category从'all'变成'digital'。
  4. @Computed检测到依赖的category变了,重新计算filteredItems,商品列表刷新。
  5. 同时,FilterBar的@Param category收到父组件同步下来的新值'digital',按钮高亮状态更新。

整条链路里,FilterBar既没有持有业务数据,也没有直接修改任何列表状态。它做的事情只有一件:把“用户点击了数码”这个事实告诉父组件。后面怎么变,全是父组件在主导。

再回头看看StatsTitle:页面创建那一刻,它收到的initCount是5,source是'GoodsPage'。后面哪怕用户切换分类,商品列表从5个变成2个,StatsTitle显示的还是5。因为@Once已经把这个值冻结在创建时刻了。这就是“创建时快照”的行为,配上这个场景刚刚好。

5. 我从@Once和@Event里踩过的四个坑

5.1 给@Once变量赋值被框架拦截

第一次用@Once时,我在StatsTitle里写了一个“更新统计”的方法,里面大大咧咧写了句this.initCount = 10。结果一运行,程序直接报错,提示@Once修饰的变量不允许重复赋值。

这个错误不会在编译期暴露,因为方法本身可能永远不会被调用到;一旦真被调用,就是运行时才炸。所以写代码的时候心里要时刻绷着一根弦:凡是@Once修饰的,创建后碰都别碰。真想更新的值,一开始就别用@Once,老老实实摆到@Local或者@Param里去。

5.2 @Event忘记初始化导致创建失败

另一个踩过的坑是@Event没给默认值。当时觉得“父组件肯定会传事件回调的”,就省了个默认空函数。结果在DevEco Studio的Previewer里单开这个组件调试时,组件创建直接挂了,控制台报的错误指向了我那个带@Event的成员变量。

原因很简单:父组件不传时,@Event变量初始值是undefined,组件创建时就违反复用规范。给个空函数当默认值,组件在任何入参组合下都能正常渲染,改代码都用不上父组件环境。以后写@Event,默认值写空函数,不要省。

5.3 把需要热更新的数据错用成@Once

这一点是最隐蔽的。我一开始写FilterBar,心想“分类参数不就是父组件传进来的吗”,顺手就定义了@Param @Once category: string = 'all'。结果跑起来发现:点“数码”按钮,父组件那边category确实变了,列表也刷新了,唯独FilterBar的三个按钮高亮死活不动。

折腾了半天才反应过来,@Once已经把初始值'all'冻在FilterBar创建那一刻了,后面父组件再怎么更新,它也收不到。这个场景的需求是“切换分类后按钮高亮要跟着变”,明显需要热更新,应该用@Param而不是@Param + @Once。

教训就一句话:@Once只服务“创建后不再变”的值,凡是需要跟着外部状态走的,一律给@Param让路。

5.4 新旧装饰器混用的隐性坑

最后一个坑也比较常见。老页面是用V1的@State/@Prop写的,我在里面加了一个用V2装饰器写的子组件,父组件用this.oldState给子组件的@Param传值。表面看数据传进去了,但父组件每次更新后,子组件不一定能及时收到同步,偶尔还出现事件回调里的this指向不对的情况。

V1和V2两套响应式系统的联动边界比较微妙,同一个页面里混着写,容易踩到预期之外的更新时序问题。迁移的时候我建议整页迁移,或者新页面直接V2,老页面保持V1,尽量避免在半改造状态下长期运行。

6. 什么场景下真正值得用@Once和@Event

6.1 这套机制的收益边界

说了这么多新特性,也得泼点冷水:并不是每个页面都必须把@Once和@Event用上才算“会V2”。

我的判断标准是这样的:

  • 组件会被多个页面复用,且对外交互比较多时,用@Event收益最明显。事件出口列清楚,调用方只用看组件头部的@Event定义,就知道这个组件能做什么。
  • 组件对外暴露的入参里有静态配置项时,用@Once能把“这个参数创建后就不动”直接写进代码,防止后续维护的人随手改坏。
  • 如果只是一个临时页面、一个一次性组件,父子关系就那么一层,普通函数参数、普通变量初始化完全够用。这时候硬套@Event和@Once,反而增加阅读负担,属于过度设计。

一句话:@Once和@Event的价值在“契约清晰”,不在“功能强大”。它们约束的东西,以前靠的是程序员自觉,比如“这个参数别改了哈”“回调记得传对”,现在变成了框架语法,IDE能检查,代码review时一眼能看出来。

6.2 和其他状态的配合建议

最后给一套我用下来比较顺手的配合套路:

  • 父组件里处理@Event回调的方法,统一用handle开头。比如handleCategoryChange、handleReset。这样读到代码时,看到handle前缀就知道这是“响应子组件事件”的入口。
  • 一组动作如果经常一起触发(比如筛选、重置、搜索),不要拆成三个@Event各传各的,可以考虑用一个事件传一个对象,减少入参数量,也方便后续扩展字段。
  • 每个组件写完后,可以回过头来看一眼它的对外声明:有几个@Param、几个@Event、哪些是@Once。如果这个清单超过四五个,就要考虑是不是拆分组件了。组件对外暴露的东西太多,本质上就是职责过重的一个信号。

我个人在实际项目里最大的体会是:状态管理V2这套东西,表面看是在“管变量”,实际上是在“管职责”。以前写组件,状态放哪、谁能改、改完怎么同步,全靠脑子里的临时约定;现在用@Once和@Event把这些约定固化成了代码。你写的时候得多想一层“这个值到底属于谁”“这个动作到底由谁响应”,但多想的这一层,会在项目规模上来之后加倍还给你。

如果你也正在从V1往V2迁,我的建议是不要一上来就铺开改全部页面,先挑一个小页面,把@Local、@Param、@Once、@Event、@Computed全套走通一遍,再回过去处理存量代码。跑通一个模块的完整闭环,远比看十遍文档有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询