HarmonyOS应用<民族图鉴>开发第7篇:状态管理——@State/@Prop/@Link/@Storage深度解析

📖 引言

上一篇我们学习了基础组件的使用,能够构建出静态的界面。但真实的应用是动态的------数据会变,用户会操作,页面之间需要传递数据。这一切的核心,就是状态管理

你可能已经用过 @State,知道它能让 UI 自动更新。但你有没有想过:

  • @Prop 和 @Link 有什么区别?什么时候用哪个?
  • @StorageLink 和 @StorageProp 又是干嘛的?
  • @Provide/@Consume 是解决什么问题的?
  • 为什么有时候改了子组件的状态,父组件没变?
  • 状态多了,管理起来乱,怎么办?

这些问题非常重要。状态管理是声明式 UI 开发的核心,也是最容易出问题的地方。状态传错了,界面就错了;状态管理乱了,代码就乱了。很多复杂应用的 bug,80% 都是状态管理的问题。

本文将以「民族图鉴」项目为载体,系统讲解 ArkUI 的状态管理体系。从最基础的 @State,到父子组件传值的 @Prop/@Link,再到全局状态的 @StorageLink,再到跨层级的 @Provide/@Consume,每一个装饰器的原理、用法、适用场景、常见坑,都会讲透。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 深入理解 ArkUI 状态管理的整体架构
  • ✅ 掌握 @State、@Prop、@Link 的区别与使用场景
  • ✅ 理解单向数据流与状态提升原则
  • ✅ 掌握 @StorageLink/@StorageProp 持久化状态的用法
  • ✅ 掌握 @Provide/@Consume 跨层级传值的原理
  • ✅ 能够根据场景选择合适的状态管理方式
  • ✅ 避开状态管理的常见陷阱

💡 需求分析

为什么需要状态管理体系?

一个简单的页面,三五个 @State 变量就能搞定。但随着应用越来越复杂,问题就来了:

问题1:父子组件怎么传值?

父组件有个数据,子组件要显示,怎么传过去?子组件改了数据,怎么通知父组件?

问题2:兄弟组件怎么共享状态?

A 页面改了设置,B 页面怎么知道?总不能每次都重新读一遍吧?

问题3:全局状态怎么管理?

主题、语言、用户信息,这些很多页面都要用的状态,放在哪?层层传递吗?

问题4:状态持久化怎么搞?

用户设置的主题、语言,下次打开应用还要保留,存在哪?怎么和 UI 联动?

为了解决这些问题,ArkUI 提供了一整套状态管理装饰器,每一种都有自己的定位和适用场景。

「民族图鉴」中的状态管理

「民族图鉴」项目虽然规模中等,但用到了几乎所有类型的状态:

状态类型 示例 使用的装饰器
组件内部状态 搜索关键词、收藏状态 @State
父→子传值(只读 民族卡片的 ethnic 属性 @Prop
全局持久化状态 当前主题、当前语言、内容模式 @StorageLink
跨页面共享状态 收藏列表、浏览历史 StorageService + @State

让我们从最简单的 @State 开始,一层层往上走。


🛠️ 核心实现

步骤1:状态管理体系全景

1.1 ArkUI 状态管理装饰器总览

ArkUI 提供了丰富的状态管理装饰器,按作用范围从小到大排列:

复制代码
┌─────────────────────────────────────────────────────┐
│  @State                                           │
│  (组件内部)                                      │
│     ┌──────────────────────────────────────┐          │
│     │  @Prop / @Link                  │          │
│     │  (父子组件之间)                 │          │
│     │    ┌─────────────────────────────┐       │          │
│     │    │  @Provide /       │       │          │
│     │    │  @Consume         │       │          │
│     │    │ (跨层级祖先→后代)  │       │          │
│     │    └─────────────────────┘       │          │
│     └──────────────────────────────────────┘          │
│                                                        │
│  @StorageLink / @StorageProp                          │
│  (全局 + 持久化,跨页面共享)                         │
└─────────────────────────────────────────────────────┘

各装饰器的定位

装饰器 作用范围 数据流方向 持久化 适用场景
@State 组件内部 自己用 组件内部状态
@Prop 父→子 单向(父→子) 子组件只读父组件数据
@Link 父↔子 双向 子组件要改父组件数据
@Observed 类装饰器 配合 @ObjectLink 嵌套对象精细化观测
@ObjectLink 子组件 双向(对象引用) 嵌套对象的子组件同步
@Provide 祖先→后代 向下提供 跨多层级传值,不用层层传
@Consume 祖先→后代 向上消费 配合 @Provide 使用
@StorageLink 全局 双向 全局共享 + 持久化
@StorageProp 全局 单向(持久化→组件) 只读持久化数据

存储级别的状态管理

存储级别 作用范围 持久化 对应装饰器 说明
AppStorage 全局,应用内 @StorageLink / @StorageProp 应用级全局状态
LocalStorage 页面/组件树内 @LocalStorageLink 页面级状态隔离
PersistentStorage 全局,持久化 @StorageLink / @StorageProp 持久化到 Preferences

💡 选择原则:能用小范围的就不用大范围的。能用 @State 解决的就不用 @Prop,能用 @Prop 解决的就不用 @Provide,能用局部状态就不用全局状态。状态范围越小,代码越容易维护。

1.2 单向数据流原则

在讲具体装饰器之前,先讲一个非常重要的原则:单向数据流(Unidirectional Data Flow)

复制代码
状态(State)
  ↓
传递给子组件(Props,只读)
  ↓
子组件触发事件(Event)
  ↓
父组件处理事件,修改状态
  ↓
状态变化 → UI 自动更新

核心思想

  • **状态在上层,子组件通过 props 接收(只读,不能改)
  • 子组件要改数据,必须发事件通知父组件,让父组件去改
  • **数据永远是从上往下流,事件从下往上冒泡

为什么要单向?

好处 说明
可预测 数据流向清晰,知道状态在哪改的
易调试 出问题了,去父组件找状态就行
易维护 不会到处都能改数据,乱套了

ArkUI 的 @Prop(单向)和 @Link(双向)就是对应这两种模式。一般推荐优先用 @Prop(单向),只有确实需要双向的时候才用 @Link。


步骤2:@State------组件内部状态

2.1 基本用法

@State 是最基础、最常用的状态装饰器,用来定义组件内部的响应式状态**。

typescript 复制代码
@Entry
@Component
struct CounterPage {
  // 组件内部的状态,只有这个组件能用
  @State count: number = 0;

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

      Button('增加')
        .onClick(() => {
          this.count++;  // 自己改自己的状态
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

@State 的特点

特点 说明
作用范围 当前组件内部
初始化 必须有初始值,不能是 undefined
响应式 变化时触发当前组件重新 build
嵌套观测 支持(对象内部属性变化也能检测到
数组方法 支持(push/pop/splice 等都会触发更新
2.2 @State 的工作原理

@State 不只是个装饰器,它做了很多底层工作:

复制代码
@State count: number = 0
    ↓
1. 创建一个 getter 和 setter
    ↓
2. 读取时(get):
   记录依赖关系
   "这个组件的 build 依赖了 count 这个状态"
    ↓
3. 写入时(set):
   - 比较新旧值
   - 如果不同,标记组件为"脏"
   - 调度下一帧重新渲染

**脏检查 + diff 更新,和 Vue 的响应式原理类似。

2.3 「民族图鉴」中的 @State 应用
typescript 复制代码
// pages/EthnicDetailPage.ets
@Entry
@Component
struct EthnicDetailPage {
  // 民族详情数据:可能还在加载
  @State ethnic: EthnicGroup | undefined = undefined;

  // 收藏状态
  @State isFavorite: boolean = false;

  // 播放状态
  @State isPlaying: boolean = false;

  // 展开状态
  @State isDescriptionExpanded: boolean = false;

  build() {
    Column() {
      if (!this.ethnic) {
        this.buildLoadingView()
      } else {
        this.buildDetailView()
      }
    }
  }
}

可以看到:

  • ethnic:页面的所有变化的所有状态都在这里
  • 每个状态负责一部分 UI
  • 状态变化,对应的 UI 自动更新
2.4 @State 使用注意事项

注意1:必须初始化

typescript 复制代码
// ❌ 错误:没有初始值
@State count: number;

// ❌ 错误:初始值是 undefined
@State user: User | undefined = undefined;

// ✅ 正确:有明确的初始值
@State count: number = 0;
@State user: User | null = null;

注意2:不要在 build 里直接修改 @State

typescript 复制代码
// ❌ 错误:build 里改状态,会导致无限循环
build() {
  this.count++;  // 改状态 → 重新 build → 又改 → 又 build...
  Text(`${this.count}`)
}

// ✅ 正确:在事件回调里改
Button('点击')
  .onClick(() => {
    this.count++;
  })

**注意3:复杂对象要注意引用类型变化才会触发更新

typescript 复制代码
@State user: User = { name: '张三', age: 25 };

// ✅ 这些都会触发更新
this.user.name = '李四';           // 直接改属性
this.user = { ...this.user, age: 26 }; // 替换整个对象

💡 好消息 :ArkUI 的 @State 支持深层观测深度观测,嵌套对象的属性变化也能检测到。这比 React 方便多了。


步骤3:@Prop------父→子单向传值

3.1 为什么需要 @Prop?

组件化开发时,父组件经常需要把数据传给子组件。比如民族卡片(EthnicCard)要显示民族信息,这些数据在列表页(EthnicListPage)。

不用 @Prop 的话怎么办?

  • 子组件自己去拿?不行,子组件应该是复用的,不该知道数据从哪来
  • 全局状态?没必要,只是父传子而已

这时候就用 @Prop------父组件把数据传过来,子组件只读显示。

3.2 基本用法
typescript 复制代码
// ========== 子组件 ==========
@Component
struct EthnicCard {
  // 用 @Prop 接收父组件传过来的数据
  // 子组件不能修改 @Prop 的值(单向,只读)
  @Prop ethnic: EthnicGroup;

  build() {
    Row({ space: 12 }) {
      Image($rawfile(`coverImage/${this.ethnic.coverImage}`))
        .width(100)
        .height(100)

      Column({ space: 6 }) {
        Text(this.ethnic.name)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)

        Text(this.ethnic.region)
          .fontSize(12)
          .fontColor('#999')
      }
      .layoutWeight(1)
    }
    .width('100%')
    .padding(16)
  }
}

// ========== 父组件 ==========
@Entry
@Component
struct EthnicListPage {
  @State ethnicList: EthnicGroup[] = [];

  build() {
    List() {
      ForEach(this.ethnicList, (item: EthnicGroup) => {
        ListItem() {
          // 父组件给子组件传值,直接写在子组件的属性上
          EthnicCard({ ethnic: item })
        }
      })
    }
    .width('100%')
  }
}

@Prop 的特点

特点 说明
数据流 单向:父→子
子组件能否修改 ❌ 不能(只读
父组件变化 ✅ 子组件自动更新
子组件变化 ❌ 不影响父组件
适用场景 子组件展示父组件的数据
3.3 @Prop 的工作原理
复制代码
父组件的状态变化
    ↓
检测到子组件的 @Prop 依赖了这个状态
    ↓
更新子组件的 @Prop 值
    ↓
子组件重新 build

@Prop 是单向的------数据只能从父流向子,不能倒流。

**子组件能不能改 @Prop?从语法上你要是改了的话

  • 子组件自己能看到变化
  • 但父组件不会变
  • 下次父组件更新时,子组件的修改会被覆盖
  • 而且这是不好的实践,不推荐这么做
3.4 「民族图鉴」中的 @Prop 应用
typescript 复制代码
// components/EthnicCard.ets
@Component
export struct EthnicCard {
  // 接收父组件传入的民族信息
  @Prop ethnic: EthnicGroup;

  build() {
    // 只显示,不修改 ethnic
    // 要跳转详情页通过 router 跳转,通过事件通知
  }
}

// pages/EthnicListPage.ets
@Entry
@Component
struct EthnicListPage {
  @State ethnicList: EthnicGroup[] = ETHNIC_GROUPS;

  build() {
    List() {
      ForEach(this.ethnicList, (item: EthnicGroup) => {
        ListItem() {
          // 把每个民族传给卡片
          EthnicCard({ ethnic: item })
        }
      })
    }
  }
}

💡 经验法则:子组件只是展示数据,不修改数据 → 用 @Prop。这是最常用的父子通信方式。


4.1 什么时候用 @Link?

有时候子组件需要修改父组件的状态。比如一个开关组件,用户点了开关,状态要变,这个状态是父组件的。

这时候 @Prop 不够用了------因为 @Prop 是单向的,子组件改了父组件不知道。

这时候就用 @Link------双向绑定。子组件改了,父组件也跟着变;父组件变了,子组件也跟着变。

4.2 基本用法
typescript 复制代码
// ========== 子组件:开关 ==========
@Component
struct ToggleSwitch {
  // 用 @Link,双向绑定
  @Link isOn: boolean;

  build() {
    Row({ space: 10 }) {
      Text(this.isOn ? '开' : '关')
        .fontSize(16)

      Button('切换')
        .onClick(() => {
          // 子组件可以直接修改 @Link 的值
          // 改了之后,父组件的状态也会变
          this.isOn = !this.isOn;
        })
    }
  }
}

// ========== 父组件 ==========
@Entry
@Component
struct SettingsPage {
  // 父组件的状态
  @State darkMode: boolean = false;

  build() {
    Column({ space: 20 }) {
      Text(`深色模式:${this.darkMode ? '开启' : '关闭'}`)
        .fontSize(18)

      // 注意:用 $ 符号传递 @Link
      // 这是关键!
      ToggleSwitch({ isOn: $darkMode })
    }
    .width('100%')
    .padding(24)
  }
}

@Link 的特点

特点 说明
数据流 双向:父↔子
父→子 父变了子自动更新
子→父 子变了父也更新
语法 父组件传值时要加 $ 前缀
适用场景 子组件需要修改父组件的状态
对比维度 @Prop @Link
数据流方向 单向(父→子) 双向(父↔子)
子组件能否修改 不能(只读)
父组件传值语法 `prop={value} prop={$value}
适用场景 展示数据 子组件要改数据
推荐程度 优先用(更安全) 必要时才用

⚠️ 注意:@Link 虽然方便,但不要滥用。数据流向越复杂,出问题越难排查。优先用 @Prop + 事件的方式,确实需要双向绑定的时候才用 @Link。

「民族图鉴」项目中 @Link 用得比较少,大多数时候用 @Prop 就够了。但有一些场景适合用 @Link:

typescript 复制代码
// 比如一个筛选面板组件
@Component
struct FilterPanel {
  @Link selectedCategory: string;
  @Link searchQuery: string;

  build() {
    // 用户选择分类、搜索都可以直接改
    // 改了之后父组件的列表自动刷新
  }
}

但大多数时候,我们的做法是:子组件触发事件,父组件处理事件修改状态。这符合单向数据流原则。


5.1 为什么需要持久化状态?

有些状态是全局的,很多页面都要用:

  • 当前主题(浅色/深色/跟随系统)
  • 当前语言(中文/英文)
  • 内容模式(全量/基础)

这些状态有两个特点:

  1. 全局共享:很多页面都要用到
  2. 需要持久化:下次打开应用还要保留用户的设置

如果每个页面都自己存一份,既麻烦又不同步。这时候就需要 @StorageLink。

@StorageLink 把组件的状态和 Preferences 持久化存储绑定起来:

typescript 复制代码
// ========== 定义常量 ==========
// 先定义 key
const KEY_THEME = 'current_theme';

// ========== 任何组件里用 ==========
@Component
struct MyComponent {
  // 和持久化存储双向绑定
  // 1. 组件初始化时,自动从 Preferences 读取
  // 2. 组件修改时,自动写回 Preferences
  // 3. 其他组件也用 @StorageLink 的话,会自动同步
  @StorageLink(KEY_THEME) currentTheme: string = 'light';

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

@StorageLink 的特点

特点 说明
持久化 ✅ 自动读写 Preferences
全局共享 ✅ 所有组件用同一个 key 就是同一份数据
双向绑定 ✅ 改了自动存,存了自动更
初始值 Preferences 没有的话,用默认值
适用场景 全局设置、用户偏好
5.3 @StorageProp:单向读取

@StorageProp 是单向的,只能从持久化读取,组件修改不写回:

typescript 复制代码
@StorageProp(KEY_THEME) currentTheme: string = 'light';
装饰器 方向 说明
@StorageLink 双向 读+自动写回
@StorageProp 单向(只读) 只读,不改持久化

一般用 @StorageLink 更多,方便。

复制代码
组件初始化
    ↓
从 Preferences 读取 key 对应的值
    ↓
如果有值 → 用这个值初始化 @State
如果没有 → 用默认值
    ↓
建立监听:
  - 组件内修改 → 自动写回 Preferences
  - Preferences 变化 → 通知所有 @StorageLink 的组件都更新

全局同步的原理

所有用同一个 key 的 @StorageLink,本质上是监听同一个持久化数据源。一方改了,持久化变了,其他人都能收到通知,自动更新。

这是「民族图鉴」项目中用得最多的全局状态管理方式:

typescript 复制代码
// common/constants/StorageConstants.ets
// 统一管理所有的 key
export const STORAGE_KEY_THEME = 'current_theme';
export const STORAGE_KEY_LANGUAGE = 'current_language';
export const STORAGE_KEY_CONTENT_MODE = 'current_content_mode';
export const STORAGE_KEY_FAVORITES = 'favorite_ethnics';
export const STORAGE_KEY_HISTORY = 'view_history';

// pages/Index.ets
@Entry
@Component
struct Index {
  // 全局主题
  @StorageLink(STORAGE_KEY_CONTENT_MODE) contentMode: string = 'full';
  @StorageLink(STORAGE_KEY_LANGUAGE) currentLanguage: AppLanguage = AppLanguage.ZH_CN;

  // 根据全局语言变化了,所有页面都知道
  private isChinese(): boolean {
    return this.currentLanguage === AppLanguage.ZH_CN;
  }
}

// pages/SettingsPage.ets
@Entry
@Component
struct SettingsPage {
  // 设置页也用同一个 key
  @StorageLink(STORAGE_KEY_LANGUAGE) currentLanguage: AppLanguage = AppLanguage.ZH_CN;

  // 这里改了语言,Index 页面自动跟着变
  build() {
    Button('切换到英文')
      .onClick(() => {
        this.currentLanguage = AppLanguage.EN;
      })
  }
}

看,全局状态就是这么简单:

  • 定义好 key
  • 每个要用的组件都 @StorageLink(key)
  • 一个地方改了,所有地方自动同步
  • 重启应用还在(持久化了)
5.6 AppStorage、LocalStorage、PersistentStorage 深度对比

很多开发者搞不清这三者的区别。让我们系统地对比一下:

对比维度 AppStorage LocalStorage PersistentStorage
作用范围 整个应用全局 单个页面/组件树 全局(持久化)
持久化 ❌ 重启丢失 ❌ 重启丢失 ✅ 重启还在
生命周期 应用启动→应用退出 页面创建→页面销毁 永久(直到卸载应用)
数据共享 所有页面共享 页面内共享 所有页面共享
对应装饰器 @StorageLink / @StorageProp @LocalStorageLink / @LocalStorageProp @StorageLink / @StorageProp
底层实现 内存中键值对 内存中键值对 Preferences(文件存储)
读写速度 快(内存) 快(内存) 稍慢(有IO)
适用场景 全局临时状态 页面内跨组件共享 用户设置、偏好

「民族图鉴」中的选择策略

复制代码
1. 全局设置(主题、语言、内容模式)
   → PersistentStorage + @StorageLink
   → 因为需要持久化,而且全局都用

2. 收藏列表、浏览历史
   → StorageService 封装 + 页面 @State
   → 数据结构复杂,业务逻辑多,Service层更合适

3. 页面内多个组件共享的临时状态
   → 状态提升到父组件(@State + @Prop)
   → 或者用 LocalStorage(页面很复杂时)

为什么「民族图鉴」不用 LocalStorage?

因为「民族图鉴」的页面结构相对简单,大多数页面的状态用 @State + @Prop 就能搞定。LocalStorage 更适合那种页面内有很多深层组件、层层传值很麻烦的场景。

💡 记忆口诀

  • 要持久化 → PersistentStorage(@StorageLink)
  • 全局临时 → AppStorage(一般用得少)
  • 页面内共享 → LocalStorage 或 状态提升
  • 简单场景 → 状态提升(@State + @Prop)最直观

为什么需要 @Observed/@ObjectLink?

@State 虽然支持嵌套对象的观测,但有时候我们需要更精细化的控制------比如一个子组件只关心对象的某个属性变化,或者对象是从外面传进来的,需要双向同步。

这时候就需要 @Observed 和 @ObjectLink 这对搭档。

基本用法
typescript 复制代码
// ========== 1. 用 @Observed 装饰类 ==========
@Observed
export class UserInfo {
  name: string = '';
  age: number = 0;
  avatar: string = '';

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}

// ========== 2. 父组件用 @State ==========
@Entry
@Component
struct UserPage {
  @State user: UserInfo = new UserInfo('张三', 25);

  build() {
    Column({ space: 20 }) {
      // 子组件A:显示姓名
      UserNameCard({ user: this.user })

      // 子组件B:修改年龄
      UserAgeEditor({ user: $user })

      Button('整体替换')
        .onClick(() => {
          this.user = new UserInfo('李四', 30);
        })
    }
    .width('100%')
    .padding(24)
  }
}

// ========== 3. 子组件用 @ObjectLink ==========
@Component
struct UserNameCard {
  // 用 @ObjectLink 接收,双向绑定对象引用
  @ObjectLink user: UserInfo;

  build() {
    Text(`姓名:${this.user.name}`)
      .fontSize(18)
  }
}

@Component
struct UserAgeEditor {
  @ObjectLink user: UserInfo;

  build() {
    Row({ space: 12 }) {
      Text(`年龄:${this.user.age}`)
        .fontSize(16)

      Button('+1')
        .onClick(() => {
          // 直接改属性,会触发UI更新
          this.user.age++;
        })
    }
  }
}

关键点

  1. 类必须用 @Observed 装饰
  2. 子组件用 @ObjectLink 接收
  3. 父组件传值时用 $ 前缀(和 @Link 类似)
  4. 修改对象属性会触发所有 @ObjectLink 的组件更新
对比维度 @Prop @Link @ObjectLink
数据流 单向(父→子) 双向 双向(对象引用)
适用类型 简单类型 + 对象 简单类型 + 对象 必须是 @Observed 装饰的类
观测粒度 整体替换才更新 整体替换才更新 属性变化也能更新
传值语法 prop={value} prop={$value} prop={$value}
适用场景 展示数据 双向绑定简单类型 嵌套对象精细化同步

💡 什么时候用 @Observed/@ObjectLink?

  • 嵌套层次深的对象(3层以上)
  • 子组件只需要修改对象的某个属性
  • 多个子组件共享同一个对象,各自修改不同属性
  • 普通 @State 满足不了需求的时候再用

步骤6:@Provide & @Consume------跨层级传值

6.1 为什么需要跨层级传值?

假设你有一个很深的组件树:

复制代码
Grandpa(爷爷组件)
  └─ Father(爸爸组件)
       └─ Son(儿子组件)
            └─ Grandson(孙子组件)

现在爷爷组件有个数据,孙子组件要用。怎么办?

  • 层层传 @Prop?传三层还好,传个三五层也还行,十几层就疯了------中间层都要加 @Prop,只为了往下传,很麻烦。

这时候 @Provide/@Consume 就派上用场了------祖先组件提供数据,后代组件直接消费,中间层不用管

6.2 基本用法
typescript 复制代码
// ========== 祖先组件:提供数据 ==========
@Component
struct GrandParent {
  // 用 @Provide 提供数据
  // 后代组件都能直接拿到
  @Provide('theme') theme: string = 'light';

  build() {
    Column() {
      Parent()  // 爸爸组件不用传任何东西
    }
  }
}

// ========== 中间层:完全不用管 ==========
@Component
struct Parent {
  build() {
    Child()  // 也不用传
  }
}

// ========== 后代组件:直接消费 ==========
@Component
struct Child {
  // 用 @Consume 消费祖先提供的数据
  // 注意:key 要对上
  @Consume('theme') theme: string;

  build() {
    Text(`主题:${this.theme}`)
  }
}

@Provide/@Consume 的特点

特点 说明
数据流 祖先 → 后代(向下)
中间层 不需要层层传递
适用场景 跨多层级传值,中间层不用这个数据
查找方式 向上找最近的 @Provide
6.3 @Provide/@Consume 的工作原理

很多人用了很久 @Provide/@Consume,但不知道它底层是怎么工作的。让我们揭开面纱:

复制代码
组件树构建过程:
  祖先组件 @Provide('theme') theme = 'light'
    ↓
  中间层组件(完全不用管)
    ↓
  后代组件 @Consume('theme') theme: string
    ↓
  原理:
  1. 后代组件初始化时,向上遍历组件树
  2. 找最近的一个 @Provide('theme')
  3. 找到后,建立订阅关系
  4. 祖先的 @Provide 变化时,通知所有 @Consume 的后代更新

两个关键特性

  1. 就近原则:如果有多个祖先都提供了同一个 key,取最近的那个
  2. 类型必须匹配:@Provide 和 @Consume 的类型要一致,否则会报错
6.4 最佳实践与常见坑

最佳实践1:key 用常量,别用魔法字符串

typescript 复制代码
// ❌ 不好:魔法字符串,拼错了都不知道
@Provide('theme') theme: string = 'light';
@Consume('theme') theme: string;

// ✅ 好:用常量,有类型检查
const PROVIDE_KEY_THEME = 'theme';
@Provide(PROVIDE_KEY_THEME) theme: string = 'light';
@Consume(PROVIDE_KEY_THEME) theme: string;

最佳实践2:不要滥用,层级不深就层层传

复制代码
组件层级 < 3 层 → 用 @Prop 层层传(数据流清晰)
组件层级 3-5 层 → 看情况,复杂了再用 @Provide
组件层级 > 5 层 → 考虑用 @Provide,或者状态管理方案

常见坑1:找不到 @Provide,运行时报错

typescript 复制代码
// ❌ 错误:祖先没有 @Provide,后代 @Consume 会报错
@Component
struct Child {
  @Consume('theme') theme: string;  // 往上找不到,崩了!
}

解决方法:确保祖先组件确实提供了,或者给 @Consume 一个默认值(如果支持的话)。

常见坑2:以为是双向的,其实是单向的

@Provide/@Consume 是单向的(祖先→后代)。后代改了 @Consume 的值,祖先不会变。

⚠️ 重要:@Provide/@Consume 是向下的单向数据流。后代组件不要修改 @Consume 的值------改了也只有自己能看到,祖先不变,而且其他后代也不会同步。要改的话,通过事件通知祖先去改。

6.5 「民族图鉴」中的应用场景

「民族图鉴」项目中 @Provide/@Consume 用得不多,因为:

  • 组件层级不深,大多数是2-3层
  • 全局状态用 @StorageLink 更方便(还持久化)
  • 父子传值用 @Prop 就够了

潜在的适用场景

typescript 复制代码
// 比如一个列表页,深层的列表项需要主题色
// pages/EthnicListPage.ets
@Entry
@Component
struct EthnicListPage {
  @State themeColor: string = '#E74C3C';

  build() {
    Column() {
      // 提供主题色给所有后代
      // 这样深层的列表项组件不用层层传
      List() {
        ForEach(this.list, (item) => {
          ListItem() {
            EthnicCard({ ethnic: item })
          }
        })
      }
    }
  }
}

// 深层的组件直接消费
// components/EthnicCard.ets
@Component
struct EthnicCard {
  @Prop ethnic: EthnicGroup;
  @Consume('themeColor') themeColor: string;  // 直接拿,不用父组件传

  build() {
    // 用主题色...
  }
}

但「民族图鉴」目前用 @StorageLink 存主题色,更方便------不仅全局共享,还持久化。

💡 选择建议

  • 全局 + 要持久化 → @StorageLink
  • 全局 + 不持久化 → AppStorage
  • 页面内 + 层级深 → @Provide/@Consume 或 LocalStorage
  • 页面内 + 层级浅 → @Prop 层层传(清晰直观)

步骤7:状态管理选型指南

学了这么多状态装饰器,什么时候用哪个?让我们来总结一下。

7.1 状态管理选型决策树
复制代码
这个状态在哪用?
  │
  ├─ 只有一个组件用
  │   └─ → @State
  │
  ├─ 父子组件之间
  │   │
  │   ├─ 子组件只读,不改
  │   │   └─ → @Prop(单向)
  │   │
  │   └─ 子组件要改
  │       │
  │       ├─ 改了父组件?
  │       │   └─ → @Link(双向)
  │       └─ 只是触发事件,父组件自己改
  │           └─ → @Prop + 事件回调(推荐,单向数据流)
  │
  ├─ 跨好几个层级
  │   │
  │   ├─ 需要持久化吗?
  │   │   ├─ 是 → @StorageLink
  │   │   └─ 否
  │   │       └─ 层级深不深?
  │   │           ├─ 不深(<3层) → 层层传 @Prop
  │   │           └─ 深 → @Provide/@Consume
  │   │
  │   └─ 很多页面都要用
  │       └─ → @StorageLink(全局共享)
  │
  └─ 复杂业务状态(很多逻辑、很多页面用、很多操作)
      └─ → Service 层 + @State(状态提升 + 单向数据流
7.2 「民族图鉴」的状态管理架构设计

让我们深入看看「民族图鉴」项目是如何设计状态管理架构的。

7.2.1 整体架构分层
复制代码
┌────────────────────────────────────────────────────┐
│                   UI 层(组件/页面)                 │
│  @State / @Prop / @Link / @StorageLink              │
│  (只负责展示和用户交互,不存业务逻辑)              │
└──────────────────────┬─────────────────────────────┘
                       │
┌──────────────────────▼─────────────────────────────┐
│                   Service 层                        │
│  StorageService / ThemeService / TTSService        │
│  (业务逻辑、数据处理、状态封装)                    │
└──────────────────────┬─────────────────────────────┘
                       │
┌──────────────────────▼─────────────────────────────┐
│                   数据层                            │
│  Preferences / 本地文件 / 网络请求                  │
│  (数据持久化、数据获取)                           │
└────────────────────────────────────────────────────┘

核心原则:状态分层,UI 层只管展示,业务逻辑放 Service,数据读写放数据层。

7.2.2 各层状态管理方式
状态类型 管理方式 示例 为什么这么选
页面内部状态 @State 搜索关键词、展开/收起、加载状态 只有当前页面用,组件内部管理
父子传值(展示) @Prop 民族卡片的 ethnic 属性 单向数据流,子组件只读
全局设置 @StorageLink 主题、语言、内容模式、字体大小 全局共享 + 需要持久化,简单键值对
业务数据 StorageService + 页面 @State 收藏列表、浏览历史、测验记录 逻辑复杂、数据量大,Service 封装更清晰
跨页面共享 Service 单例 ThemeService、TTSService 有复杂业务逻辑,不止是存数据
7.2.3 为什么业务数据不用 @StorageLink?

这是一个非常好的问题。收藏列表也是全局的,也需要持久化,为什么不用 @StorageLink?

原因1:@StorageLink 适合简单类型

@StorageLink 对简单类型(string/number/boolean)支持最好。对于数组、对象这种复杂类型:

  • 需要自己 JSON.stringify/parse
  • 数组的 push/pop 能不能自动同步?不一定
  • 类型安全没保障

原因2:业务逻辑放 Service 更清晰

收藏功能不只是"存一个数组",它有很多业务逻辑:

  • toggleFavorite:切换收藏状态(有则删,无则加)
  • isFavorite:判断是否已收藏
  • getFavoriteList:获取收藏列表
  • 去重、数量限制、排序...

这些逻辑如果散落在各个组件里,维护起来很麻烦。放到 StorageService 里统一管理,清晰多了。

原因3:易于测试和复用

Service 层是纯逻辑,不依赖 UI,容易写单元测试。而且多个页面可以复用同一个 Service。

7.2.4 「民族图鉴」状态管理代码示例
typescript 复制代码
// ========== 常量层:统一管理 key ==========
// common/constants/StorageConstants.ets
export class StorageConstants {
  static readonly STORE_NAME: string = 'ethnic_encyclopedia_store';
  static readonly KEY_THEME_MODE: string = 'theme_mode';
  static readonly KEY_APP_LANGUAGE: string = 'app_language';
  static readonly KEY_CONTENT_MODE: string = 'content_mode';
  static readonly KEY_FAVORITE_ETHNICS: string = 'favorite_ethnics';
  static readonly KEY_VIEWED_ETHNICS: string = 'viewed_ethnics';
}

// ========== Service 层:封装业务逻辑 ==========
// services/StorageService.ets
export class StorageService {
  private static instance: StorageService;
  private preferences: preferences.Preferences | null = null;

  public static getInstance(): StorageService {
    if (!StorageService.instance) {
      StorageService.instance = new StorageService();
    }
    return StorageService.instance;
  }

  // 收藏民族
  public async toggleFavoriteEthnic(ethnicId: string): Promise<boolean> {
    const favorites = await this.getFavoriteEthnics();
    const index = favorites.indexOf(ethnicId);
    if (index > -1) {
      favorites.splice(index, 1);
      await this.saveString(
        StorageConstants.KEY_FAVORITE_ETHNICS,
        JSON.stringify(favorites)
      );
      return false;
    } else {
      favorites.push(ethnicId);
      await this.saveString(
        StorageConstants.KEY_FAVORITE_ETHNICS,
        JSON.stringify(favorites)
      );
      return true;
    }
  }

  // 获取收藏列表
  public async getFavoriteEthnics(): Promise<string[]> {
    const jsonStr = await this.getString(
      StorageConstants.KEY_FAVORITE_ETHNICS,
      '[]'
    );
    try {
      return JSON.parse(jsonStr);
    } catch (e) {
      return [];
    }
  }
}

// ========== UI 层:页面使用 ==========
// pages/EthnicDetailPage.ets
@Entry
@Component
struct EthnicDetailPage {
  @State ethnic: EthnicGroup | undefined = undefined;
  @State isFavorite: boolean = false;

  aboutToAppear(): void {
    // 进入页面时加载收藏状态
    this.loadFavoriteStatus();
  }

  private async loadFavoriteStatus(): Promise<void> {
    if (!this.ethnic) return;
    const storage = StorageService.getInstance();
    this.isFavorite = await storage.isFavoriteEthnic(this.ethnic.id);
  }

  private async toggleFavorite(): Promise<void> {
    if (!this.ethnic) return;
    const storage = StorageService.getInstance();
    this.isFavorite = await storage.toggleFavoriteEthnic(this.ethnic.id);
  }
}
7.2.5 架构设计心得
  1. 不要什么都用 @StorageLink:简单设置用它,复杂业务用 Service
  2. 状态能不下放就不下放:能放父组件就不放子组件,能放 Service 就不放全局
  3. 业务逻辑要封装:不要散落在组件里,抽到 Service 层统一管理
  4. 常量统一管理:key 不要魔法字符串,统一放 constants 文件

💡 最佳实践:状态管理的最高境界是"恰到好处"------不多不少,不越界不错位。每个状态都有它该在的位置,找到了那个位置,代码就清晰了。


⚠️ 常见问题与解决方案

问题1:@Prop 子组件改了为什么父组件没变?

现象

子组件里改了 @Prop 的值,子组件自己显示变了,但父组件没变。

原因

@Prop 是单向的,数据只能从父到子。子组件改 @Prop,改的是自己的那份副本,父组件不知道。

解决方法

方法1:用 @Link(双向绑定)

typescript 复制代码
// 子组件
@Link value: string;

// 父组件
Child({ value: $value })

方法2:@Prop + 事件回调(推荐,单向数据流)

typescript 复制代码
// 子组件:触发事件
@Prop value: string;
onChange: (newValue: string) => void = () => {};

Button('修改')
  .onClick(() => {
    // 自己不改,通知父组件改
    this.onChange('新值');
  })

// 父组件:处理事件,自己改
@State parentValue: string = '';

Child({
  value: this.parentValue,
  onChange: (newVal: string) => {
    this.parentValue = newVal;
  }
})

💡 推荐用方法2。单向数据流,数据流向更清晰,出了问题容易查。


现象

用了 @StorageLink,但重启应用数据没了,或者不同页面不同步。

常见原因

原因 说明
key 写错了 两个组件用的 key 不一样,当然不同步
初始值类型不一样 类型不同,各是各的
存的是复杂对象 @StorageLink 只支持简单类型?
没等异步?不, Preferences 是异步的,但 @StorageLink 封装好了
页面还没初始化完就读 页面刚创建,还没从 Preferences 读出来

排查步骤

复制代码
第1步:确认 key 完全一样
  - 拼写对不对?有没有拼写错误?
  - 最好用常量,别手写字符串,容易写错
    ↓
第2步:确认是简单类型(string/number/boolean)
  - 对象的话,要 JSON.stringify/parse
    ↓
第3步:打日志确认存上了吗?
  - 改了之后,等一下再看
  - 或者用 Preferences 的 get 一下看看
    ↓
第4步:Clean + Rebuild
  - 有时候是缓存的问题

最佳实践

typescript 复制代码
// 1. key 用常量统一管理
// common/constants/StorageConstants.ets
export const KEY_THEME = 'current_theme';

// 2. 用的时候引用常量
@StorageLink(KEY_THEME) theme: string = 'light';

// 3. 复杂对象自己序列化
// 不用 @StorageLink 存数组,用 Service 层封装

问题3:对象引用、数组更新、嵌套对象的常见坑

状态管理中最容易出问题的就是复杂类型(对象、数组)。让我们一一拆解。

坑1:以为改了对象属性会更新,但没更新?

好消息 :ArkUI 的 @State 支持深层观测,嵌套对象的属性变化也能检测到!

typescript 复制代码
@State user: User = {
  name: '张三',
  profile: {
    age: 25
  }
};

// ✅ 这些都会触发更新
this.user.name = '李四';           // 直接改属性
this.user.profile.age = 26;        // 改嵌套属性
this.user = { ...this.user, name: '王五' }; // 替换整个对象

💡 这一点比 React 方便多了!React 里要确保返回新的引用,ArkUI 不用。

坑2:数组更新的正确姿势

@State 也能检测数组的变化:

typescript 复制代码
@State list: string[] = ['A', 'B', 'C'];

// ✅ 这些都会触发更新
this.list.push('D');              // push
this.list.pop();                   // pop
this.list.splice(1, 1);            // splice
this.list[0] = '新值';             // 直接改索引
this.list = ['X', 'Y', 'Z'];       // 替换整个数组

「民族图鉴」中的实际应用

typescript 复制代码
// 收藏列表的切换
@State favoriteList: string[] = [];

toggleFavorite(ethnicId: string): void {
  const index = this.favoriteList.indexOf(ethnicId);
  if (index > -1) {
    // 移除
    this.favoriteList.splice(index, 1);
  } else {
    // 添加
    this.favoriteList.push(ethnicId);
  }
  // 不用手动触发更新,@State 自动检测
}
坑3:@Prop 传对象,子组件改属性会怎么样?

这是一个经典的坑!@Prop 是单向的,但如果传的是对象,子组件改对象的属性,父组件会不会变?

答案:会! 因为对象是引用类型,@Prop 复制的是引用,不是对象本身。

typescript 复制代码
// 父组件
@State user: User = { name: '张三', age: 25 };

// 子组件
@Prop user: User;

// 子组件改属性
this.user.age = 26;  // ⚠️ 父组件的 user.age 也会变!

为什么会这样?

复制代码
@State user: User (内存地址 0x123)
    ↓ 传递给 @Prop
@Prop user: User (也是内存地址 0x123,同一个对象)

子组件改 user.age → 改的是同一个对象 → 父组件也能看到变化

但这不是"双向绑定"

  • 子组件改属性,父组件能看到(因为同一个对象引用)
  • 但父组件整体替换对象时,子组件会更新
  • 这和 @Link 的双向绑定不是一回事

最佳实践

  • 子组件不要直接修改 @Prop 对象的属性
  • 要修改的话,通过事件通知父组件
  • 符合单向数据流原则
坑4:@State 初始化是 undefined 怎么办?

问题:@State 要求必须有初始值,但有些数据(比如从接口获取的)一开始就是空的。

解决方案

typescript 复制代码
// 方案1:用 null 作为初始值
@State ethnic: EthnicGroup | null = null;

// 方案2:用 undefined 加类型联合
@State ethnic: EthnicGroup | undefined = undefined;

// 方案3:给一个默认的空对象
@State ethnic: EthnicGroup = {
  id: '',
  name: '',
  coverImage: '',
  // ... 其他字段给默认值
};

「民族图鉴」的做法

typescript 复制代码
// pages/EthnicDetailPage.ets
@State ethnic: EthnicGroup | undefined = undefined;

build() {
  Column() {
    if (!this.ethnic) {
      // 加载中视图
      this.buildLoadingView()
    } else {
      // 正常显示
      this.buildDetailView()
    }
  }
}
坑5:什么时候该用 @Observed/@ObjectLink?

判断标准

场景 用什么
简单类型(string/number/boolean) @State + @Prop/@Link
扁平对象(1-2层属性) @State + @Prop(改属性会同步,但不推荐)
深层嵌套对象(3层+) @Observed + @ObjectLink
子组件需要修改对象的某个属性 @Observed + @ObjectLink
多个子组件共享同一个对象,各改各的 @Observed + @ObjectLink

⚠️ 记住:@Observed 是类装饰器,要加在 class 上面,不是属性上面。@ObjectLink 是组件属性装饰器,配合 @Observed 类使用。


问题4:状态太多,组件很乱,怎么办?

现象

一个组件里十几个 @State,看着就晕,不知道哪个管什么。

解决思路

思路1:相关的状态合并成对象

typescript 复制代码
// ❌ 散落在外
@State userName: string = '';
@State userAge: number = 0;
@State userAvatar: string = '';
@State userLevel: number = 0;

// ✅ 合并成一个对象
interface UserInfo {
  name: string;
  age: number;
  avatar: string;
  level: number;
}

@State user: UserInfo = {
  name: '',
  age: 0,
  avatar: '',
  level: 0
};

思路2:拆分组件

组件太大,状态太多,说明职责太多了。拆成小组件,每个小组件只管自己的状态。

思路3:状态提升到父组件/Service

如果状态很多组件共用,提到父组件或者 Service 层。

思路4:用 Store 模式(复杂应用)

非常复杂的状态管理(比如一个很大的应用),可以考虑类似 Pinia 的 Store 模式,自己封装。

💡 经验法则

  • 3-5 个状态:正常
  • 5-10 个状态:考虑拆组件
  • 10 个以上:肯定要拆了,或者抽 Service

问题4:什么时候该用全局状态?

现象

不知道什么状态该放全局,什么放局部。

判断标准

该放全局的信号 不该放全局的信号
很多页面都要用 只有一两个页面用
用户设置、偏好 临时状态、表单输入
需要持久化保存 临时计算出来的
应用级别的配置 某个功能的状态

「民族图鉴」的全局状态

✅ 该全局:

  • 主题设置
  • 语言设置
  • 内容模式(全量/基础)

❌ 不该全局:

  • 搜索关键词(每个页面自己的)
  • 列表滚动位置(每个列表自己的)
  • 当前选中的分类(每个列表自己的)

⚠️ 全局状态是双刃剑。用好了方便,用多了就是灾难------到处都能改,出了问题不知道谁改的。能不用全局的就不用全局,能局部的就局部。


现象

都是状态,都能触发 UI 更新,区别在哪?

全面对比

对比维度 @State @StorageLink
作用范围 组件内部 全局(所有组件)
持久化 ❌ 重启就没了 ✅ 重启还在
共享 自己用 所有组件共享
初始化 必须给初始值 从 Preferences 读,没有用默认值
性能 稍慢(有 IO)
适用场景 组件内部状态 全局设置、用户偏好

怎么选?

  • 只是这个组件自己用的 → @State
  • 很多组件都要用 + 要持久化 → @StorageLink

📝 本章小结

核心知识点

本文系统讲解了 ArkUI 的状态管理体系:

1. 状态管理全景

  • 从小到大:@State → @Prop/@Link → @Provide/@Consume → @StorageLink
  • 选择原则:能用小范围不用大范围
  • 单向数据流:数据从上往下流,事件从下往上冒泡

2. @State

  • 组件内部状态,最基础最常用
  • 必须初始化,不能是 undefined
  • 支持嵌套观测、数组方法检测
  • 脏检查 + diff 更新机制

3. @Prop(单向传值

  • 父→子,单向,只读
  • 父变子自动更新,子改父不知道
  • 最常用的父子通信方式

4. @Link双向绑定

  • 父↔子,双向绑定,子改父也改
  • 父组件传值要加 $ 前缀
  • 方便但不要滥用,优先用单向 + 事件

5. @StorageLink / @StorageProp

  • 全局共享 + 持久化
  • 自动读写 Preferences
  • 一个地方改,所有地方同步
  • 适合全局设置、用户偏好

6. @Provide / @Consume

  • 跨层级传值,中间层不用管
  • 祖先 @Provide,后代 @Consume
  • 方便但数据流不直观,调试麻烦
  • 层级不深就层层传 @Prop

7. 选型指南

  • 一个组件用 → @State
  • 父子展示 → @Prop
  • 父子双向 → @Link(或 @Prop+事件)
  • 全局+持久化 → @StorageLink
  • 跨多层 → @Provide/@Consume(慎用)
  • 复杂业务 → Service层 + 状态提升

最佳实践总结

优先用小范围状态

复制代码
能用 @State 不用 @Prop
能用 @Prop 不用 @Link
能用局部不用全局
范围越小,代码越容易维护

单向数据流优先

typescript 复制代码
// 推荐:@Prop + 事件回调
// 数据向下,事件向上
@Prop value: string;
onChange: (newVal: string) => void;

// 子组件触发事件,父组件自己改
// 数据流向清晰,好调试

全局状态用常量管理 key

typescript 复制代码
// 统一放在 constants 文件里
export const KEY_THEME = 'current_theme';

// 用的时候引用常量
@StorageLink(KEY_THEME) theme: string = 'light';

// 避免手写字符串,拼错了都不知道

状态不要冗余

复制代码
能算出来的就不要存
能从别的状态推出来的就不要单独存
状态越少越好维护

组件状态太多就拆分

复制代码
一个组件十几个状态?
拆!拆成多个小组件
每个组件职责单一,状态少,好维护

不要什么都放全局

复制代码
全局状态是方便
但也容易乱
只有真正全局的、要持久化的才放
能用局部就局部

下一步预告

在下一篇文章中,我们将:

  • 🧭 深入学习鸿蒙应用的路由导航系统
  • 📄 掌握页面跳转的多种方式与区别
  • 📤 学习页面间参数传递与数据传递方式
  • 🔙 掌握返回传参与页面返回栈管理
  • 🚀 为多页面应用开发打下基础

🔗 相关链接


💡 提示:状态管理是声明式 UI 开发的灵魂。状态管理做得好,代码清晰、bug 少、好维护;状态管理做得乱,到处都是 bug,改都没法改。不要着急写代码的时候多想想:这个状态应该放在哪?数据流是怎样的?想清楚了再动手,比写完了再改强多了。好的状态管理,是好应用的基石。