本节摘要:组件树里的状态传递有四种主力装饰器:@Prop 单向同步、@Link 双向同步、@Provide/@Consume 跨层共享、@Observed+@ObjectLink 嵌套对象联动。本节用购物车场景把四种写法各跑一遍,给出选择判据。
第 4.1 节的状态都住在页面里。一旦界面拆成组件,数据就得流动。先给结论表,再逐个验证:
| 装饰器 | 方向 | 数据特征 | 典型场景 |
|---|---|---|---|
| @Prop | 父到子单向 | 本地副本,子改不影响父 | 展示型子组件接收标题、标签 |
| @Link | 父子双向 | 引用同步,双向联动 | 开关、计数器、表单控件 |
| @Provide + @Consume | 跨层任意 | 祖先提供,后代消费 | 全局登录态、主题色 |
| @Observed + @ObjectLink | 嵌套对象 | 对象级精确刷新 | 列表项对象内部字段变化 |
场景驱动:一个购物车页面。页面持有商品列表与总数量;列表项是子组件,可勾选;顶部还有一个跨了几层嵌套的结算条组件显示已选数量。这个场景恰好把四种通行证全部用上。
@Prop:给展示组件喂只读数据。 列表项的商品名、价格是纯展示:
@Component struct PriceTag { @Prop price: number = 0 build() { Text(`¥${this.price.toFixed(2)}`).fontSize(16) } }
@Prop 生成副本,父组件更新时同步给子,子组件内部修改只改自己的副本,不会回传。展示型组件一律优先 @Prop——数据流向单一,出问题好查。
@Link:勾选状态双向联动。 每个列表项有勾选态,页面需要感知它来计算总价:
@Component struct CartItem { @Link checked: boolean build() { Toggle({ type: ToggleType.Checkbox, isOn: this.checked }) .onChange((v: boolean) => { this.checked = v }) } } // 页面里使用:CartItem({ checked: this.itemChecked[i] })
@Link 是引用同步:父里改、子刷新;子里改、父也刷新。传参时父侧不加 的写法在新版本已兼容,但读旧代码时见到 value 前缀要知道那是 @Link 的老式传参语法,指传引用而非拷贝。
@Provide/@Consume:结算条跨层取数。 结算条埋在很深的布局嵌套里,把已选数量一层层透传要穿过三四个中间组件——中间组件被迫接收并转发与自己无关的数据,这叫"prop 钻透",是组件设计的坏味道。跨层共享的解法:
@Entry @Component struct CartPage { @Provide('selectedCount') selectedCount: number = 0 build() { Column() { this.cartList() SettlementBar() } } } @Component struct SettlementBar { @Consume('selectedCount') selectedCount: number build() { Text(`已选 ${this.selectedCount} 件,去结算`).fontSize(18) } }
祖先 @Provide 声明,任意层级的后代 @Consume 消费,同名匹配。代价是数据来源不再显式(看 SettlementBar 的代码不知道数据从哪来),所以判据是:跨三层以上且语义上是"全局/区域共享"的状态才用,两层以内老老实实传参。
@Observed + @ObjectLink:列表项对象内部联动。 商品对象有 name、price、checked 多个字段,列表项组件接收整个对象并要对其内部字段变化做精确刷新:
@Observed class Goods { name: string price: number checked: boolean constructor(name: string, price: number) { this.name = name this.price = price this.checked = false } } @Component struct GoodsRow { @ObjectLink goods: Goods build() { Row({ space: 12 }) { Toggle({ type: ToggleType.Checkbox, isOn: this.goods.checked }) .onChange((v: boolean) => { this.goods.checked = v }) Text(this.goods.name).layoutWeight(1) PriceTag({ price: this.goods.price }) }.padding(12) } }
@Observed 标记类,@ObjectLink 接收实例,goods.checked 的修改就能触发 GoodsRow 精确刷新——这正好补上了 4.1 节"嵌套对象内部变化不可见"的缺口,且刷新范围只有这一行,列表其他项纹丝不动。
再往外一层是应用级共享。AppStorage 是应用内存里的全局键值仓库,任何组件通过链接接口与某个键绑定:
// 登录成功后写入 AppStorage.setOrCreate('userName', '小舟'); AppStorage.setOrCreate('isVip', true); // 任意组件里双向链接 @StorageLink('isVip') isVip: boolean = false @StorageProp('userName') userName: string = ''
@StorageLink 双向、@StorageProp 单向,语义与 @Link/@Prop 平行。购物车的"用户是否会员"影响价格显示,这类跨页面共享的登录态、会员态就放 AppStorage。与它相邻的两个工具:PersistentStorage 把指定键自动落盘(重启恢复,底层是轻量 preferences,第 5 章详讲存储侧),Environment 提供只读的设备环境(语言、深浅色)。应用级状态的纪律与 @Provide 相同:真正全局的才上,且键名集中定义,别散落魔法字符串。
把上面碎片拼起来。背景:商品 20 件,列表可勾选,结算条实时显示已选件数与总价,会员价在总价上打九折。操作:Goods 类加 @Observed;页面 @State goodsList 数组 + @Provide selectedCount;GoodsRow 用 @ObjectLink 接对象,勾选时改 goods.checked 并回调整 selectedCount;结算条 @Consume 件数、@StorageLink 读 isVip;PriceTag 用 @Prop。结果:勾选任意商品,只有该行与结算条刷新,件数、价格(含会员折扣)全部实时正确。解读:五种装饰器各司其职,数据流向可以画成一张单向图——对象状态在 Goods 实例里,跨组件信号沿 Provide/Storage 走,展示副本经 Prop 下发。变式:加"全选"开关(页面级遍历设置所有 goods.checked),观察 @ObjectLink 如何让每行各自刷新——这就是选对装饰器的复利。
⚠️ 常见坑:@Consume 与 @Provide 的名字不匹配时,运行才报错(找不到提供者),编译期无提示。名字统一用常量定义可以从根上杜绝。
💡 关键直觉:装饰器的选择本质是回答"谁拥有数据、谁有权修改"。展示的用 Prop、联动的用 Link、共享的用 Provide/Storage、嵌套的用 ObjectLink——先回答权属问题,装饰器自然就选出来了。
本节要点回顾:
这些机制都属于"V1 体系"。HarmonyOS 还有一套 V2 状态管理,观察粒度与写法都不同。下一节正面回答:新项目该用哪套。