状态管理系列从最基础的一对装饰器讲起:@State 与 @Prop。这对组合看起来是文档里最先教的内容,实际工程里踩的坑却最多------"改了值界面不刷新""子组件改父组件的值报错""对象嵌套一层就失效",三个问题背后是同一个模型:单向数据流。这篇用健康菜谱助手的真实代码把单向流讲透,以及它失效的三种场景。

一、模型:@State 是源,@Prop 是副本
@State 标记的状态是数据的"源头",拥有它并负责它的变更;@Prop 标记的是从父组件单向同步过来的"副本",子组件读它没问题,改它只改自己的副本且改动不会回流。菜谱卡片实例化时把菜名从列表页传下来,卡片内改 @Prop 菜名,列表页的列表不会有任何变化------这不是 bug,是单向流的设计意图:数据只能从源头单向流动,变更要回到源头去改,再重新同步下来。
ts
@Component
struct RecipeCard {
@Prop name: string // 副本:子组件可读,改了不回流
build() {
Text(this.name)
}
}
// 父组件
@State recipeName: string = '红烧肉'
RecipeCard({ name: this.recipeName })
二、为什么改 @Prop 不刷新父组件:设计意图而非缺陷
团队新同学最困惑的就是这个行为。解释要从状态所有权讲起:一份状态只能有一个所有者(@State),其他组件只是订阅者(@Prop)。如果 @Prop 的修改能回流,同一份数据就有两个可写的源头,哪次修改生效、以谁为准就成了竞态------单向流的存在就是为了消灭这类竞态。正确做法:子组件要改数据,通过回调通知父组件,父组件改 @State,新值自动同步下来。回调加状态提升(把状态提到公共父级),是 ArkUI 状态管理的第一性用法。
ts
@Component
struct RecipeCard {
@Prop name: string
onRename?: (newName: string) => void
build() {
Text(this.name).onClick(() => {
this.onRename?.(this.name + '(改)') // 发回调,不改自己的Prop
})
}
}
// 父组件持有@State并提供回调,数据流闭环
三、失效场景一:对象嵌套一层就观测不到
@State 的观测能力有明确的边界:第一层属性可观测,嵌套第二层开始变化不触发刷新。菜谱对象 { meta: { name, duration }, tags: \[\] },修改 this.recipe.meta.name = '新名字',界面纹丝不动------@State 只观测到 meta 这个引用没变。三种解法按成本排序:解构赋值整层替换(this.recipe = {...this.recipe, meta: newMeta}),成本是整层重渲染;拆平数据结构让可变字段都在第一层,成本是建模调整;用 @Observed 加 @ObjectLink 精准观测嵌套对象,成本是引入新的机制。小对象用第一种,大列表用第三种,拆平是首选。
| 解法 | 成本 | 适用 |
|---|---|---|
| 整层替换 | 整层重渲染 | 嵌套浅、更新不频繁 |
| 拆平数据结构 | 建模调整 | 首选,长期收益 |
| @Observed/@ObjectLink | 新机制 | 大列表精准刷新 |
四、失效场景二:数组内单项属性修改不刷新
@State 数组的观测同样只到第一层:数组的增删、整体替换会刷新,但修改数组里某一项的属性------this.recipes3.name = '新名'------不刷新。收藏列表的真实 bug 就来自这里:点击收藏切换某项的 isFav 字段,界面无反应。解法与对象场景同理,推荐用 splice 整项替换(this.recipes.splice(3, 1, newItem)),既触发刷新又保持其他项不动。这个写法在项目里被封装成数组工具方法,统一了全应用的处理方式。
ts
// 错误:不触发刷新
this.recipes[3].isFav = true
// 正确:整项替换
const item = { ...this.recipes[3], isFav: true }
this.recipes.splice(3, 1, item)
五、@Prop 的深拷贝成本:大对象慎用
@Prop 在初始化时会对父组件传入的值做深拷贝(为了保证副本独立性),这个行为在大对象上代价显著。实测一个含一千二百条菜谱的对象用 @Prop 传递,初始化耗时增加四十毫秒且内存翻倍------拷贝了整个数据集。解法:@Prop 只传子组件真正需要的切片(传 id 加展示字段,不传全量对象);大集合的传递改用 @ObjectLink 引用(不拷贝)配合 @Observed。@Prop 的定位是轻量副本,重传大对象是对它的滥用。

六、@Prop 的同步时机:单向且一次性初始化后持续同步
@Prop 与父组件 @State 的同步是持续的:父组件的值变化,@Prop 自动收到新值并刷新------前提是类型匹配且路径上的组件没有断链。踩过的坑:@Prop 传函数不生效(@Prop 不支持函数类型),回调要用普通属性声明(不用装饰器);@Prop 传对象后父组件只改了对象内部属性,子组件不刷新(回到嵌套观测边界问题)。@Prop 的同步规则记住一句:父变子变,子变不回传,深嵌套断观测。
七、实战案例:筛选面板的单向流改造
筛选面板最初把筛选条件写成子组件的 @State,父页面的列表读不到筛选结果,数据流断裂。第一轮修复用双向绑定 @Link,能用但筛选逻辑散落在子组件里。第二轮按单向流重构:筛选条件提升到父页面 @State,子组件用 @Prop 接收展示、用回调上报变更,筛选逻辑集中在父页面一个方法里。重构后筛选逻辑可测试(纯函数)、可追踪(变更点唯一),代价是子组件多了几个回调属性------回调的啰嗦换来了数据流的清晰,值得。这个案例确立了项目的状态管理守则第一条:状态提升到最近的公共所有者,子组件无状态化。
ts
@Component
struct FilterPanel {
@Prop filter: FilterState // 展示:父传下来
onFilterChange?: (f: FilterState) => void // 上报:回调
build() {
Row() {
Text(this.filter.category).onClick(() => {
this.onFilterChange?.({ ...this.filter, category: 'all' })
})
}
}
}

八、@State 初始化的时机与重置
@State 的初始化只在组件创建时执行一次,后续父组件重新渲染不会重置它------除非组件被销毁重建。这个特性在复用篇里讨论过(复用回填替代重建),在本篇的语境下有另一个推论:页面间返回时,页面级 @State 保持上次离开时的值,这就是"返回后筛选条件还在"的实现基础。反过来,希望每次进入页面都重置的状态,要么放在路由参数里传入,要么在 aboutToAppear 里显式重置。@State 的生命周期与组件生命周期绑定,想清楚哪些状态要跨页面保留、哪些要一次性,建模就不会乱。
九、调试:状态变化的观测手段
状态不刷新的排查,靠 console.log 散点打印效率低。两个手段:第一,ArkUI Inspector 的状态追踪视图能看到每个组件依赖的状态变量与最近一次刷新原因;第二,自定义日志装饰------把状态变更集中到一个 setState 工具方法里(而不是散落各处直接赋值),方法内打印变更前后的值与触发源。筛选面板重构后,所有状态变更都走 setState,一次数据流断裂问题靠日志十分钟定位------状态变更的可观测性,和布局的可观测性一样值得投入。
十、总结:单向流的四条守则
收束成四条:@State 只在所有者组件里改,子组件通过回调申请变更;@Prop 只传轻量切片,大对象用 @ObjectLink;嵌套观测有边界,数据结构尽量拆平或在边界处整层替换;状态变更集中入口,保持可观测。四条守则执行后,状态不刷新类的问题在项目里基本绝迹。单向流不是 ArkUI 的限制,是所有现代前端框架共同的选择------把变更路径约束成单向,可预测性就有了保障。
参考资料:
- 华为开发者:@State 装饰器
- 华为开发者:@Prop 装饰器
- 华为开发者:状态管理概述
#鸿蒙 #HarmonyOS 7(API 26)
十一、@Prop 与 @Link 的选择:一句话判断法
讲完单向流,@Link 的出现时机就清晰了:当子组件确实需要修改父组件的状态、且这个修改要立即反映回父组件时,用 @Link 双向同步;只需要展示或临时本地差异时,用 @Prop。一句话判断法:子组件是"展示者"用 @Prop,是"共同所有者"用 @Link。菜谱应用里的对照:菜名展示用 @Prop(展示者);收藏开关用 @Link(开关状态父子共同所有,子组件的操作要立即反映到父组件的列表)。选择标准不是语法便利,是所有权语义------谁拥有这份数据的决定权。
ts
@Component
struct FavToggle {
@Link isFav: boolean // 双向:开关即所有
build() {
Image(this.isFav ? 'fav_on' : 'fav_off').onClick(() => { this.isFav = !this.isFav })
}
}
// 父:@Link 传引用用 $ 前缀
FavToggle({ isFav: $isFav })
十二、@Link 的传参细节:$ 前缀与初始化规则
@Link 的父组件传参必须加 前缀(FavToggle({ isFav: isFav }))------传的是引用而不是值,漏了 $ 会被当成普通初始化,双向同步失效且不报错,子组件改了状态父组件毫无反应。这个坑的隐蔽性在于一切看起来正常,只有交互时才发现断链。第二个规则:@Link 不能有本地默认值,必须由父组件初始化------它是引用,不是副本。把这两条写进评审检查单后,断链类问题在新代码里绝迹。
十三、状态风暴:过度细粒度 @State 的反噬
单向流用顺手后容易走向另一个极端:所有细粒度数据都做成独立 @State。菜谱详情页最初有二十三个 @State 变量(菜名、图片列表、步骤数组、每个弹窗开关......),状态间有隐含联动(时长变化要重算难度),结果一处变更触发多个看似无关的组件刷新,页面出现"集体闪动"。重构思路是把强相关的状态合并成对象建模(用接口分组),弱相关的保持独立;联动的派生值用计算属性思路集中计算而不是散落监听。@State 的粒度是设计问题:太粗刷新范围大,太细联动管理难,按"业务上一起变化的数据放一组"划分,是实践里最稳的粒度。
| 粒度 | 问题 | 症状 |
|---|---|---|
| 过粗(一个大对象) | 嵌套观测失效+全量刷新 | 无关区域跟着刷新 |
| 过细(二十多个散装) | 联动管理困难 | 状态风暴、集体闪动 |
| 按业务分组(推荐) | --- | 刷新精准,联动集中 |
十四、状态不刷新排查的固定三板斧
状态类问题的排查固化成三板斧。第一斧,确认观测边界:改动的字段是不是 @State/@Prop 观测范围内的第一层?嵌套了就整层替换或拆平。第二斧,确认变更路径:这次赋值走的是统一入口还是散落赋值?变更点有没有被状态工具方法捕获?第三斧,确认组件身份:刷新的组件是不是你盯着的那一个?复用场景下组件实例是复用的,身份错认是常见误判。三板斧走完仍无头绪的,再上 Inspector 的状态追踪视图。三板斧解决掉的状态问题占比在八成以上------多数时候不是问题复杂,是没有按顺序排查。
十五、总结:单向流的成本与回报
单向流的成本显而易见:子组件改数据要写回调,数据要提升,代码比双向绑定啰嗦。回报在三周后显现:状态相关的 bug 定位时间从半天级缩短到分钟级------因为每份数据只有一个所有者,变更路径唯一,出问题只需要查一条路径。这份可预测性是所有现代化状态管理的共同追求,ArkUI 的 @State/@Prop 只是把这件事用装饰器语法表达出来。理解了所有权与单向流,后面篇幅里的 @Link、@Provide/@Inject、AppStorage 都只是"同步范围不同"的变体,模型一通百通。系列开篇就到这里,下一篇拆 @Link 的双向同步细节------那是一把更锋利也更危险的刀。状态管理系列的每一篇都会附上一份可以对照自查的守则清单,积少成多,最终汇成整个应用的状态管理规范------这份规范,就是下一篇 @Link 双向同步的对照组与出发点。
参考资料:
- 华为开发者:@State 装饰器
- 华为开发者:@Prop 与 @Link
- 华为开发者:状态管理最佳实践
#鸿蒙 #HarmonyOS 7(API 26)