[鸿蒙从零到一] ArkUI 状态管理实战:从 @State 到 @Provide 与 @Consume

ArkUI 状态管理实战:从 @State 到 @Provide 与 @Consume

在 ArkUI 的声明式开发模式中,页面并不是靠手动查找控件并修改属性来刷新,而是由状态驱动界面重新构建。状态变化后,框架识别依赖该状态的 UI,并更新相关部分。

这套机制看起来简单,真正落到业务中却经常遇到几个问题:数据应该放在父组件还是子组件?子组件能不能直接修改父组件的数据?多层组件之间如何共享状态?为什么修改了对象内部字段,界面却没有按预期更新?

本文围绕 @State@Prop@Link@Provide@Consume 展开,通过一个设置面板的演进过程,梳理 ArkUI 中常用的状态传递方式、适用边界和常见误区。

一、先理解状态驱动 UI

下面是一个最小示例:

ts 复制代码
@Entry
@Component
struct CounterPage {
  @State count: number = 0

  build() {
    Column({ space: 16 }) {
      Text(`当前计数:${this.count}`)
        .fontSize(24)

      Button('增加')
        .onClick(() => {
          this.count++
        })
    }
    .width('100%')
    .padding(24)
  }
}

这里的核心关系是:

  • count 是组件内部状态;
  • Text 在构建时读取了 count
  • 点击按钮修改 count
  • ArkUI 发现状态变化后,更新依赖它的界面。

因此,状态管理装饰器不是简单的语法标记。它们定义了数据由谁持有、向哪里流动、谁可以修改,以及修改后哪些组件需要刷新。

二、@State:组件内部拥有的可变状态

@State 适合由当前组件持有并维护的数据。它通常有明确的初始值,生命周期与组件实例一致。

ts 复制代码
@Component
struct SearchPanel {
  @State keyword: string = ''
  @State loading: boolean = false

  build() {
    Column({ space: 12 }) {
      TextInput({ placeholder: '输入关键词', text: this.keyword })
        .onChange((value: string) => {
          this.keyword = value
        })

      Button(this.loading ? '搜索中...' : '搜索')
        .enabled(!this.loading && this.keyword.trim().length > 0)
        .onClick(() => {
          this.loading = true
          // 完成异步请求后再将 loading 设为 false
        })
    }
  }
}

keywordloading 都只服务于 SearchPanel 自身,由它直接修改很自然。

@State 的适用场景

  • 输入框当前内容;
  • 展开或收起状态;
  • 当前选中的页签;
  • 加载、空数据、错误等页面状态;
  • 只在当前组件内部使用的临时交互数据。

不要无条件把数据都放进 @State

如果数据的真正所有者是父组件,子组件再复制一份 @State,就会出现两个状态源。父组件更新时,子组件的副本未必同步;子组件修改时,父组件也不知道发生了什么。

判断标准可以简化为一句话:谁负责决定这个值,谁就应该持有它。

三、@Prop:父组件向子组件单向传值

@Prop 用于父组件向子组件传递数据。子组件可以读取,也可以在自身作用域内修改,但这种修改不会反向改变父组件中的源数据。

下面用一个音量展示组件说明:

ts 复制代码
@Entry
@Component
struct SettingsPage {
  @State volume: number = 60

  build() {
    Column({ space: 16 }) {
      Text(`父组件音量:${this.volume}`)

      VolumePreview({ value: this.volume })

      Button('父组件设置为 80')
        .onClick(() => {
          this.volume = 80
        })
    }
    .padding(24)
  }
}

@Component
struct VolumePreview {
  @Prop value: number

  build() {
    Column({ space: 8 }) {
      Text(`预览音量:${this.value}`)

      Button('仅修改预览值')
        .onClick(() => {
          this.value = 20
        })
    }
  }
}

父组件的 volume 变化时,新值会同步给子组件的 value。但子组件执行 this.value = 20 时,只改变自己的本地副本,不会把父组件的 volume 改成 20。

@Prop 适合表达"输入参数"

典型场景包括:

  • 卡片标题、描述、图标地址;
  • 是否显示某个区域;
  • 组件的初始配置;
  • 子组件只负责展示,不负责决定的数据。

当子组件需要把用户操作通知父组件,但不希望直接修改父状态时,可以配合回调函数:

ts 复制代码
@Component
struct ThemeOption {
  @Prop selected: boolean
  label: string = ''
  onSelect: () => void = () => {}

  build() {
    Row() {
      Text(this.label)
        .layoutWeight(1)
      Text(this.selected ? '已选择' : '')
    }
    .width('100%')
    .padding(16)
    .onClick(() => this.onSelect())
  }
}

父组件仍然是状态的唯一所有者,子组件只发出事件。这种"数据向下、事件向上"的方式边界清晰,适合大多数组件封装。

四、@Link:父子组件双向同步

如果子组件不仅展示数据,还需要直接编辑父组件持有的状态,可以使用 @Link

父组件传递时需要使用 $ 建立双向关联:

ts 复制代码
@Entry
@Component
struct ProfileEditorPage {
  @State nickname: string = 'Harmony Developer'

  build() {
    Column({ space: 16 }) {
      Text(`当前昵称:${this.nickname}`)

      NicknameEditor({ nickname: $nickname })
    }
    .padding(24)
  }
}

@Component
struct NicknameEditor {
  @Link nickname: string

  build() {
    TextInput({ text: this.nickname })
      .onChange((value: string) => {
        this.nickname = value
      })
  }
}

输入框修改 nickname 后,父组件中的同一状态也会变化,依赖它的 Text 随之刷新。

需求 推荐方式
子组件只展示父数据 @Prop
子组件维护自己的临时副本 @Prop 或独立 @State,并明确同步时机
子组件直接编辑父状态 @Link
子组件通过事件请求父组件修改 @Prop 配合回调

@Link 使用方便,但不应滥用。大量子组件都能直接修改同一份父状态时,数据变更来源会难以追踪。对于保存、删除、提交等带业务含义的操作,回调通常比双向绑定更容易维护;对于开关、滑块、文本编辑器等"值编辑器",@Link 往往更自然。

五、用 @Provide 与 @Consume 跨层共享状态

当组件层级变深时,逐层传递参数会产生冗长的中间代码。有些中间组件本身并不使用数据,却必须接收并继续向下传递。

@Provide@Consume 可以建立祖先组件和后代组件之间的状态共享关系:祖先提供状态,后代按名称消费状态,中间层不需要显式透传。

ts 复制代码
@Entry
@Component
struct AppSettingsPage {
  @Provide fontScale: number = 1.0

  build() {
    Column({ space: 20 }) {
      Text('阅读设置')
        .fontSize(26)

      SettingsContent()
    }
    .width('100%')
    .padding(24)
  }
}

@Component
struct SettingsContent {
  build() {
    Column() {
      ReadingPreview()
    }
  }
}

@Component
struct ReadingPreview {
  @Consume fontScale: number

  build() {
    Column({ space: 12 }) {
      Text('这是一段预览文字')
        .fontSize(18 * this.fontScale)

      Slider({
        value: this.fontScale,
        min: 0.8,
        max: 1.4,
        step: 0.1
      })
        .onChange((value: number) => {
          this.fontScale = value
        })
    }
  }
}

ReadingPreview 修改 fontScale 后,提供方和其他消费方可以感知同一状态的变化。这比把参数穿过每一层组件更简洁。

使用别名降低耦合

当属性名可能不同,或希望明确共享状态的语义时,可以使用别名:

ts 复制代码
@Component
struct SettingsRoot {
  @Provide('appTheme') themeMode: string = 'light'

  build() {
    ThemeSection()
  }
}

@Component
struct ThemeSection {
  @Consume('appTheme') currentTheme: string

  build() {
    Text(`当前主题:${this.currentTheme}`)
  }
}

别名让提供方和消费方不必使用相同的属性名,也能减少大型页面中同名状态带来的歧义。

@Provide 与 @Consume 的边界

它们适合共享具有页面级或组件子树级语义的数据,例如:

  • 页面主题和显示密度;
  • 表单编辑上下文;
  • 多个后代组件共同使用的筛选条件;
  • 一个复杂业务组件内部共享的状态。

它们不适合替代所有参数传递。一个可复用组件如果依赖外部隐式提供的数据,单独使用和测试时会更困难。组件的核心输入仍应优先通过普通属性、@Prop@Link 明确表达。

六、综合实战:可复用的通知设置面板

下面把几种状态方式组合起来:页面持有设置数据,分组组件接收展示参数,开关组件双向修改状态,深层预览组件消费页面提供的主题。

ts 复制代码
@Entry
@Component
struct NotificationSettingsPage {
  @State messageEnabled: boolean = true
  @State soundEnabled: boolean = false
  @Provide('settingsTheme') theme: string = 'light'

  build() {
    Column({ space: 16 }) {
      Row() {
        Text('通知设置')
          .fontSize(26)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)

        Button(this.theme === 'light' ? '深色' : '浅色')
          .onClick(() => {
            this.theme = this.theme === 'light' ? 'dark' : 'light'
          })
      }
      .width('100%')

      SettingGroup({ title: '消息提醒' }) {
        SettingSwitch({
          label: '接收新消息通知',
          checked: $messageEnabled
        })

        SettingSwitch({
          label: '播放提示音',
          checked: $soundEnabled
        })
      }

      NotificationPreview()
    }
    .width('100%')
    .padding(24)
  }
}

@Component
struct SettingSwitch {
  @Prop label: string
  @Link checked: boolean

  build() {
    Row() {
      Text(this.label)
        .fontSize(16)
        .layoutWeight(1)

      Toggle({ type: ToggleType.Switch, isOn: this.checked })
        .onChange((value: boolean) => {
          this.checked = value
        })
    }
    .width('100%')
    .height(56)
  }
}

@Component
struct NotificationPreview {
  @Consume('settingsTheme') theme: string

  build() {
    Text('通知预览')
      .width('100%')
      .padding(16)
      .fontColor(this.theme === 'light' ? Color.Black : Color.White)
      .backgroundColor(this.theme === 'light' ? '#F5F5F5' : '#303030')
  }
}

其中 SettingGroup 可以是带 @BuilderParam 的容器组件。为突出状态流转,上面的代码省略了它的实现。整个页面的数据职责非常明确:

  • NotificationSettingsPage 持有业务设置;
  • SettingSwitch 作为值编辑器,通过 @Link 修改对应开关;
  • label 只是展示输入,使用 @Prop
  • theme 属于整个组件子树的显示上下文,通过 @Provide 共享;
  • NotificationPreview 使用 @Consume 获取并修改共享状态。

七、对象与数组状态的更新注意事项

基础类型的变化容易识别,复杂对象和数组则要特别注意观察深度以及 API 版本差异。

ts 复制代码
class UserProfile {
  name: string = ''
  age: number = 0
}

@Component
struct ProfileCard {
  @State profile: UserProfile = {
    name: 'ArkTS User',
    age: 20
  }

  build() {
    Column() {
      Text(this.profile.name)
      Text(`${this.profile.age}`)

      Button('更新年龄')
        .onClick(() => {
          this.profile = {
            name: this.profile.name,
            age: this.profile.age + 1
          }
        })
    }
  }
}

重新赋值一个新对象通常能清晰地触发状态变更,也符合不可变更新的思路。对于数组,可以使用生成新数组的方式:

ts 复制代码
this.items = [...this.items, newItem]
this.items = this.items.filter(item => item.id !== targetId)

在具体 HarmonyOS SDK 版本中,旧版装饰器对嵌套对象属性的观察能力存在边界。复杂数据模型可以结合 @Observed@ObjectLink,或在采用状态管理 V2 的工程中使用对应的新装饰器。实际开发应以项目使用的 API 版本和官方文档为准,不要混用两套状态管理模型的写法。

八、常见问题与排查方法

子组件修改后,父组件没有变化

先检查子组件使用的是 @Prop 还是 @Link@Prop 的本地修改不会回写父组件;需要双向同步时使用 @Link,并在父组件调用处通过 $变量名 传递。

父组件和子组件显示不同步

检查是否在两端分别创建了 @State,从而形成两个状态源。保留一个真正的数据所有者,其余组件通过参数、链接或事件访问它。

修改对象字段后界面没有刷新

确认被读取的字段是否处于框架可观察范围。可以先采用对象整体重新赋值验证,再根据 SDK 版本选择适合复杂对象的观察装饰器。

@Consume 找不到对应状态

检查组件树上方是否存在匹配的 @Provide,名称或别名是否一致,以及消费组件是否确实位于提供组件的后代节点中。

页面状态越来越难维护

这通常不是装饰器数量的问题,而是所有权不清晰。按以下顺序整理:

  • 找出每份状态的唯一所有者;
  • 区分纯展示参数和值编辑器;
  • 把带业务含义的修改封装成事件或方法;
  • 仅对真正跨层共享的上下文使用 @Provide@Consume
  • 避免多个组件随意修改同一业务状态。

九、状态选型速查

装饰器 数据所有者 传递方向 子组件修改是否影响源数据 典型用途
@State 当前组件 组件内部 当前组件就是源数据 页面交互状态
@Prop 父组件 父到子 展示参数、初始配置
@Link 父组件 父子双向 输入框、开关、滑块等值编辑器
@Provide 祖先组件 向后代提供 与消费方共享 组件子树上下文
@Consume 消费祖先提供的状态 祖先与后代同步 跨层读取和编辑共享状态

总结

ArkUI 状态管理的关键不是记住装饰器语法,而是建立清晰的数据所有权:

  • 组件自己的可变数据使用 @State
  • 父组件向子组件提供只读语义的数据使用 @Prop
  • 子组件作为值编辑器时使用 @Link
  • 跨越多层组件共享上下文时使用 @Provide@Consume
  • 复杂业务操作优先通过回调或明确的方法表达,避免任意位置直接修改状态。

当每份状态都有唯一、清楚的所有者,组件之间的数据流就更容易理解,界面刷新问题也更容易定位。随着项目规模增长,这种边界意识比单纯增加状态装饰器更重要。

相关推荐
Hilaku18 小时前
为什么很多人觉得前端很简单?
前端·javascript·程序员
hunterandroid18 小时前
[鸿蒙从零到一] ArkUI 列表与网格实战:List、Grid 与 LazyForEach
前端
李明卫杭州18 小时前
Vue2 数组响应式为什么总"不更新"?深入源码对比 Vue3 的 Proxy 改造
前端·javascript·vue.js
索西引擎18 小时前
【React】useReducer 与 useState 的比较研究:复杂状态管理场景下的选型
前端·react.js·前端框架
hunterandroid18 小时前
前台服务适配与线上排查:通知权限、启动限制和任务保活
android·前端
我是大卫18 小时前
【图】React源码解析-从“上帝视角”俯瞰React的认知框架
前端·react.js·源码
不简说18 小时前
JS 代码技巧 vol.6 — 20 个性能优化野路子,从渲染到网络全栈提速
前端·javascript·程序员
小岛前端18 小时前
展示!我 Vibe 了一个 Codex HUD
前端·ai编程
我是大卫19 小时前
【图】React源码解析-从源码数据结构、执行机制对比、闭包陷阱与最佳实践四个维度,深挖useMemo和useCallback的底层原理
前端·react.js·源码