
最近在过鸿蒙状态管理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组件内部可变状态StateLocal父传子参数后续会更新PropParam父传子参数只管初始值手工赋值绕来绕去Param Once子组件反向影响父组件Link 或 回调函数Event计算派生状态手工计算或Watch里改Computed1.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: ArrayGoodsItem [ new GoodsItem(手机, 3999, digital), new GoodsItem(笔记本电脑, 6999, digital), new GoodsItem(冰箱, 2999, home), new GoodsItem(洗衣机, 2499, home), new GoodsItem(空调, 3299, home) ]; Computed get filteredItems(): ArrayGoodsItem { 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 时序分析用户点击按钮后发生了什么代码写完了重点看一遍交互发生的完整链路。以用户点击“数码”按钮为例FilterBar内部按钮的onClick触发调用this.onCategoryChange(digital)。这个函数不是FilterBar自己的逻辑而是父组件在创建FilterBar时通过Event传进去的那个箭头函数里面执行的是this.category category。父组件GoodsPage的category从all变成digital。Computed检测到依赖的category变了重新计算filteredItems商品列表刷新。同时FilterBar的Param category收到父组件同步下来的新值digital按钮高亮状态更新。整条链路里FilterBar既没有持有业务数据也没有直接修改任何列表状态。它做的事情只有一件把“用户点击了数码”这个事实告诉父组件。后面怎么变全是父组件在主导。再回头看看StatsTitle页面创建那一刻它收到的initCount是5source是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和Event6.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全套走通一遍再回过去处理存量代码。跑通一个模块的完整闭环远比看十遍文档有用。