☰
ArkTS状态管理与组件通信:从@State到@Link的完整实战
2026/10/14 5:26:20 网站建设 项目流程

部分。我把前两篇没细讲的状态管理和组件通信一次性补齐,这两块是新手从“能写页面”跨到“能写应用”必经的一道坎。

这个系列前两篇聊过环境搭建和 ArkTS 基础语法,能读到这篇说明你已经对@Entry、@Component、build()这些基础概念不陌生了。这篇我不打算再重复讲声明式 UI 是什么,而是直接拿一个记账本应用当靶子,从状态管理到组件间数据传递,再到条件渲染和列表渲染,把这些东西揉碎了讲清楚。读完你至少能把一个“看起来能跑”的 Demo,改造成一个“逻辑拎得清”的小应用。

我假设你已经能跑起来一个 Hello World 工程,但对着官方文档里@State、@Prop、@Link这些装饰器一脸懵。别急,这篇就是为这个时刻准备的。

1. 内容整体设计与思路拆解

1.1 为什么第三篇必须啃状态管理

很多新手写完静态页面之后,第一个撞上的墙就是“数据变了,界面没反应”。你可能会想:那我直接改Text组件的字符串不就行了?在命令式框架里确实可以,但在 ArkTS 的声明式 UI 范式里,你的任务不是“操作组件”,而是“描述状态”。

换句话说,你要告诉系统的是“现在账单总金额是 128.5 元”,而不是“去把那个 Text 的内容改成 128.5”。系统监听到状态变化后,自己会去找哪些组件依赖了这个状态,然后精准地刷新它们。这个机制的核心,就是装饰器。

这一篇的核心设计思路是:围绕状态管理,把数据怎么存、怎么改、怎么传,一条线全串起来。我选了记账本这个场景,因为它天然需要处理数据的新增、汇总、筛选、列表展示,几乎覆盖了日常开发 80% 的 ArkTS 状态管理需求。

1.2 从一个记账本 Demo 看全局

我的模拟项目是一个极简记账本,功能就三个:记录一笔支出(金额 + 分类)、展示当前的支出总金额、按分类筛选账单列表。

你可能会说这也太简单了,但就是这三件事,逼着你搞清楚以下问题:

  1. 账单列表数据放在哪个组件里?
  2. 新增一笔账单,状态在子组件里改了,怎么让父组件知道?
  3. 总金额和账单列表是两个组件,怎么共享同一个数据源?
  4. 筛选条件变化时,列表怎么自动跟着变?

这四个问题全部解决,你就已经不是 ArkTS 入门水平了。这篇不会给你甩一堆概念,而是带你把每个问题过一遍,顺便把你以后一定会踩的坑提前告诉你。

2. 核心细节解析与实操要点

2.1@State:组件内部状态的地基

@State是 ArkTS 状态管理里最基本的一个装饰器。它的作用是:让一个变量变成“响应式”的——变量值一变,依赖它的 UI 自动刷新。

举个例子,我要显示“当前总支出”,我会这么写:

@Entry @Component struct Index { @State totalExpense: number = 0; build() { Column() { Text(`当前总支出:${this.totalExpense} 元`) .fontSize(24) .fontWeight(FontWeight.Bold) Button('模拟一笔支出') .onClick(() => { this.totalExpense += 25.5; }) } .padding(20) } }

重点来了:totalExpense被@State修饰后,它的读取和修改都必须通过this来访问。因为只有经过@State包装后的属性,才是在状态管理框架的“监控范围”内的。

你要是直接写let x = 1这种普通局部变量,那这个变量跟 UI 没有任何关系,改了也白改。

特别注意:@State修饰的变量必须是组件内部自己的状态。它不是用来做跨组件通信的,跨组件通信要用后面讲的@Prop、@Link这些。

2.2@Prop:父传子的单向数据流

很快你会遇到一个场景:记账本里,账单列表是子组件,它需要从父组件拿到“账单数据”,然后渲染出来。这时候@Prop上场。

@Prop的特点是:父组件数据可以初始化它,但子组件不能反向修改父组件的数据。也就是说,它是一种单向数据流约束。

@Component struct BillList { @Prop bills: BillItem[] = []; build() { List({ space: 10 }) { ForEach(this.bills, (bill: BillItem) => { ListItem() { Row() { Text(bill.category) Text(`¥${bill.amount.toFixed(2)}`) } } }) } } }

父组件里这样传数据:

BillList({ bills: this.billData })

这里有个非常隐蔽的坑,我栽过一次:@Prop修饰的变量,必须要赋初始值。如果你写@Prop bills: BillItem[];,编译不给报错,但运行时子组件首帧渲染拿到的可能是 undefined,在ForEach里访问bill.category直接白屏。

一开始我很不理解,为什么 ArkTS 要这么设计。后来想通了:@Prop的语义是“由外部初始化,内部只读展示”,如果你不给初值,首帧渲染时组件根本不知道你要展示的是什么。

2.3@Link:双向同步的父子通信

单独记账要用,这种需求用@Prop不行。改分类要把数据回传给父组件,才能让总金额跟着刷新。这时候用@Link。

@Component struct AddBillPanel { @Link billData: BillItem[]; @Link totalExpense: number; build() { Column() { Button('添加一笔 30 元的餐饮支出') .onClick(() => { this.billData.push({ category: '餐饮', amount: 30, timestamp: Date.now() }); this.totalExpense += 30; }) } } }

父组件调用时,注意写法:要在变量前加$符号,表示“双向绑定”:

AddBillPanel({ billData: $billData, totalExpense: $totalExpense })

这个$符号是新手最容易忘的。忘了的后果是:编译不报错,但子组件里改动根本不生效,而且你找半天也找不到逻辑错误,因为你潜意识里认为“代码没问题”。

为什么设计成这样?我拿生活化的例子给你解释:@Prop是“复印件的照片”——你拿着照片怎么改都影响不了原件;@Link是“同一条绳上的两把钥匙”——无论哪边拧一下,两把锁的锁芯都一起动。ArkTS 要用$这个显式标记,让你在代码里一眼就看出“这里建立的是双向连接”,而不是隐式地让所有传参都变成双向的——那会带来巨大的性能开销。

2.4@Observed和@ObjectLink:对象内部变化的监听

这个知识点我纠结了一下要不要写进“入门”篇,但后来还是决定写——因为这是你在实际项目里一定会碰到的。

用记账本场景来说:如果你把账单存成了一个对象数组,对象里是BillItem实例。然后你把整个数组用@State传到子组件里,子组件里修改某个BillItem的amount,UI 会不会刷新?

答案是:不会刷新。因为@State监听的是数组引用本身的变化(比如push、splice),但数组里某个对象的属性变了,@State根本感知不到。

解决办法是把BillItem用@Observed修饰,子组件里接收单个对象用@ObjectLink:

@Observed class BillItem { category: string = ''; amount: number = 0; timestamp: number = 0; constructor(category: string, amount: number, timestamp: number) { this.category = category; this.amount = amount; this.timestamp = timestamp; } }

子组件接收时:

@Component struct BillListItem { @ObjectLink bill: BillItem; build() { Row() { Text(this.bill.category) Text(`¥${this.bill.amount.toFixed(2)}`) Button('改金额') .onClick(() => { this.bill.amount = 99; }) } } }

@ObjectLink监听的是这个对象的“内部属性变化”。只要是@Observed类实例的属性被改了,UI 就跟着变。

这一块的难点在于理解分层:@State管“数组有没有变”,@ObjectLink管“对象内部有没有变”。两者的边界搞清楚,复杂数据类型的状态管理就不是问题。

2.5@Builder: 复用 UI 结构和提高可读性

状态管理讲完了,接着聊聊@Builder。它的作用是:把一段重复的 UI 结构抽出来复用。比如账单列表里每个账单行,你不想在ForEach里堆一堆Row、Column、Text,而是想抽一个独立构建函数。

@Builder billRow(bill: BillItem) { Row() { Text(bill.category) .fontSize(16) Blank() Text(`¥${bill.amount.toFixed(2)}`) .fontSize(16) .fontWeight(FontWeight.Bold) } .width('100%') .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .backgroundColor('#FFFFFF') .borderRadius(8) }

然后在组件里调用:

ForEach(this.bills, (bill: BillItem) => { ListItem() { this.billRow(bill) } })

这玩意儿不是我硬塞给你的知识,而是 ArkTS 项目里非常常见的代码组织方式。你去看很多开源项目,几乎每个组件里都有一堆@Builder方法。它能极大提升代码可读性,也能避免在build()里出现几百行的大嵌套。

3. 实操过程与核心环节实现

3.1 开发环境与工程初始化

开始写代码前,先确认你手里的开发环境能跑起来。我在某公司的实际开发中用的是 API 12 及以上版本的 SDK,配套的 DevEco Studio 版本建议保持最新,因为 ArkTS 的编译器一直在收紧类型检查规则。

如果你照着旧版本文档写代码,经常会遇到“这写法怎么不行了”的情况。ArkTS 为了性能做了很多编译期优化,规则比 TypeScript 严格得多。所以:

  1. DevEco Studio 保持更新;
  2. 遇到编译报错优先去查当前版本 SDK 的官方文档;
  3. 别用 TypeScript 的习惯写 ArkTS。

新建工程时,选择“Empty Ability”模板即可。我一般会把build-profile.json5里的compileSdkVersion和targetSdkVersion保持一致,避免奇奇怪怪的兼容性问题。

3.2 定义账单数据模型

按照前面讲的,我们先把账单的数据结构定义出来:

// models/BillModel.ets @Observed export class BillItem { category: string = ''; amount: number = 0; timestamp: number = 0; constructor(category: string, amount: number, timestamp: number) { this.category = category; this.amount = amount; this.timestamp = timestamp; } }

注意:ArkTS 里export的用法和 TypeScript 一样,但类属性必须初始化。不初始化会报“Property 'category' has no initializer”的错。这是 ArkTS 与 TS 的一个显著区别——它要求属性有明确的初始值。

3.3 主页面:组合状态与子组件

接下来是主页面。这里我会把“总金额展示”和“账单列表”分成两个子组件来组织。

// pages/Index.ets import { BillItem } from '../models/BillModel'; @Component struct TotalSummary { @Prop totalExpense: number = 0; build() { Column() { Text('本月总支出') .fontSize(14) .fontColor('#A0A0A0') Text(`¥${this.totalExpense.toFixed(2)}`) .fontSize(36) .fontWeight(FontWeight.Bold) .margin({ top: 4 }) } .width('100%') .padding(20) .backgroundColor('#FFF9F0') .borderRadius(16) } } @Component struct BillList { @Prop bills: BillItem[] = []; @Prop filterCategory: string = ''; build() { List({ space: 8 }) { ForEach( this.bills.filter(b => this.filterCategory === '' || b.category === this.filterCategory), (bill: BillItem) => { ListItem() { Row() { Text(bill.category) .fontSize(16) Blank() Text(`¥${bill.amount.toFixed(2)}`) .fontSize(16) .fontWeight(FontWeight.Bold) } .padding(16) .backgroundColor('#FFFFFF') .borderRadius(8) } }, (bill: BillItem) => bill.timestamp.toString() ) } .width('100%') .layoutWeight(1) .backgroundColor('#F5F5F5') } }

这里我特意在ForEach里加了第三个参数:键值生成函数。ArkTS 的ForEach默认用数组项的索引来标识每一项,但列表一变(比如新增、删除),索引对不上就会导致整个列表重新渲染,严重的还会闪烁。用timestamp.toString()做唯一 ID,能避免这种无意义的全量刷新。

3.4 添加账单面板与子组件

现在写添加账单面板。为了不让示例太复杂,我们模拟三种固定分类的快捷添加按钮。

@Component struct AddBillPanel { @Link billData: BillItem[]; @Link totalExpense: number; private addBill(category: string, amount: number): void { this.billData.push(new BillItem(category, amount, Date.now())); this.totalExpense += amount; } build() { Row({ space: 8 }) { Button('餐饮 +20') .onClick(() => this.addBill('餐饮', 20)) Button('交通 +5') .onClick(() => this.addBill('交通', 5)) Button('购物 +50') .onClick(() => this.addBill('购物', 50)) } .width('100%') .justifyContent(FlexAlign.Center) } }

主页面把三个子组件组装起来:

@Entry @Component struct Index { @State billData: BillItem[] = []; @State totalExpense: number = 0; build() { Column({ space: 16 }) { TotalSummary({ totalExpense: this.totalExpense }) AddBillPanel({ billData: $billData, totalExpense: $totalExpense }) BillList({ bills: this.billData }) } .width('100%') .height('100%') .padding(16) .backgroundColor('#F5F5F5') } }

这段代码跑起来,你会发现一个有意思的现象:TotalSummary里通过@Prop接收的totalExpense会跟着变,BillList里的账单也实时刷新。整个应用的状态流是:AddBillPanel里修改数据,通过@Link同步回Index里的@State,再通过@Prop单向流给其他子组件。

这就是 ArkTS 状态管理的核心数据流模型——状态提升 + 单向数据流。

3.5 分类筛选:状态派生与计算

前面BillList里已经写了filter逻辑,但筛选条件这个状态放哪?我建议放在Index页面里,再传给BillList。这样做的好处是:筛选条件变化时,Index作为“单一数据源”重新下发,子组件不需要自己维护一套筛选状态。

@State filterCategory: string = ''; build() { Column({ space: 16 }) { Row({ space: 8 }) { ForEach(['全部', '餐饮', '交通', '购物'], (category: string) => { Button(category) .fontColor(this.filterCategory === category ? '#FFFFFF' : '#333333') .backgroundColor(this.filterCategory === category ? '#FF6B00' : '#FFFFFF') .onClick(() => { this.filterCategory = category; }) }) } // ... } }

然后在BillList传参时加上:

BillList({ bills: $billData, filterCategory: this.filterCategory })

等一下,这里有一个细节:bills我用了$billData而不是this.billData。这合理吗?其实这里因为billData本身只会被AddBillPanel修改,BillList只读展示,用this.billData传就行。但如果你在子组件里需要修改数组内容(比如删除一条),那就必须用$billData建立双向绑定。

我上面的代码特意改成$billData,是为了让你“养成习惯”:凡是传给子组件的引用类型数据,一律用$绑定。因为后期需求一变(比如加个删除按钮),你不需要回头改传参方式。

3.6 联动总金额的实时计算

如果筛选后只显示部分账单,总金额要不要跟着变?这个问题没有标准答案,取决于产品需求。但作为一个开发实例,我们需要的是:总金额始终是所有账单的汇总,不随筛选变化。

因为我们把totalExpense作为独立@State维护,所以它天然就不受筛选影响。不过我个人的实践习惯是:总金额这种由数据派生出来的状态,能不手工维护就不要手工维护。手工维护的问题是,你在push一条记录时忘了+= amount,总金额就不对了。

更好的做法是写一个计算属性:

get totalExpense(): number { return this.billData.reduce((sum, bill) => sum + bill.amount, 0); }

这样totalExpense就不是一个@State,而是一个 getter。每次billData变化,build()重新执行,getter 重新计算,UI 上显示的总金额自然刷新。

注意:ArkTS 里 getter 修饰符不支持@State。一个属性不能既是 getter 又是状态变量。你可以理解为:getter 本身是“只读的、由其他状态派生的”,它的值来源是已经响应式的billData,所以 UI 能够正确追踪依赖并刷新。

全部代码里,我建议“能用 getter 用 getter,能少维护一个状态就少维护一个”。这能帮你减少大量手工同步状态的 bug。

4. 常见问题与排查技巧实录

4.1 数据变了 UI 没刷新?

这是状态管理新手最常见的问题,而且排查起来极其难受,因为逻辑根本没写错,数据看起来也改了。

我的排查清单如下:

序号检查项原因与解法
1变量有没有被@State/@Prop/@Link修饰?普通变量不触发 UI 刷新
2@Link传参时有没有加$?忘记加$会导致“改了没反应”
3修改的是数组本身,还是数组成员?修改数组成员对象属性要用@Observed+@ObjectLink
4子组件里修改状态,父组件为什么没变?如果子组件用了@Prop,那是单向的,要改成@Link
5是不是在ForEach里用了索引作为 key?用唯一的key避免渲染错乱和刷新异常

实测下来,80% 的情况都是因为这五点里的某一点。尤其是第二点,写@Link忘加$的隐蔽性最大。

4.2 不存在的对象属性

ArkTS 对类的类型限制比 TypeScript 严格。假设你写:

let bill = new BillItem('餐饮', 20, Date.now()); bill.note = '午餐';

编译器会直接报错,因为BillItem类里没有定义note属性。这不是你写错了,而是 ArkTS 强制要求属性必须在类定义里声明。解决办法是:

@Observed export class BillItem { category: string = ''; amount: number = 0; timestamp: number = 0; note: string = ''; // 提前声明 }

这是跟 JavaScript 思维最不对付的地方——不能随意给对象挂新属性。但反过来想,这也是好事:类型系统帮你兜底,少了很多“运行时undefined访问”的崩溃。

4.3 页面启动白屏或闪退

出现这种情况,先检查有没有在build()里访问了还没初始化的数组元素。比如账单列表是空的,ForEach里却写了bill.amount,这时取不到字段会直接抛异常。

我写代码的习惯是,凡是数据类属性,初始化都给一个安全的默认值:

class BillItem { category: string = '未知'; // 而不是 '' amount: number = 0; timestamp: number = 0; }

这样即使某些逻辑出问题,UI 也不会因为undefined崩溃,最多显示“¥0.00”或者“未知分类”,顶多算视觉瑕疵,不至于白屏。从定位问题的难度来说,白屏要远比显示“¥0.00”难排查——后者至少你能看到页面结构是正常的。

4.4 状态管理里对象的引用陷阱

@State数组里的对象,你push一个之后,如果后来改了对象的属性(比如bill.category = '交通'),但因为category不是@Observed修饰的类,UI 不会刷新。

前面提过用@Observed,但这里还有个细节:@Observed只监听属性的变化,不监听“属性指向的对象的变化”。也就是如果category是 string,直接改没问题;但如果category本身是对象,而且你替换了整个对象,那么这不算属性变化,UI 不刷新。

解决方式有两种:

  1. 用不可变数据模式,每次修改生成新对象并替换数组里的旧对象;
  2. 用@Observed+@ObjectLink的组合监听对象属性变化。

一般来说,对于这种多层嵌套对象,我推荐方案 1——不是因为它有多“正确”,而是因为它最容易理解和排查。

4.5 筛选列表不刷新的元凶

你可能已经照着代码写完了,却发现筛选点击后列表纹丝不动。检查一下你在BillList里传的是this.billData还是filterCategory。

如果filterCategory没有用@Prop接收,而是直接在子组件里写了一个普通变量,那么它变化时ForEach里 filter 逻辑不会被重新执行。这个问题的本质是:ArkTS 的 UI 刷新,必须通过状态变量触发,普通变量对 UI 没有任何感知能力。

我第一次踩这个坑时,翻来覆去找了半天,最后发现问题出在“筛选条件没变成响应式数据”上。解决很简单:把filterCategory声明为@Prop filterCategory: string = '';,一切就正常了。

5. 试着再往前走一步:把状态管理真正用对地方

博文写到这里,核心内容其实已经完了。但我还是想跳出记账本这个具体例子,聊聊状态管理的思维方式,因为它直接影响你后续所有 ArkTS 开发的代码组织能力。

5.1 一个组件里有两个状态,怎么摆放?

我见过很多新手把页面所有状态堆在一个@Entry组件里,一个页面几百行状态声明,子组件全是纯展示组件。这个做法其实并不好。

正确思路是:状态尽量下沉到离它最近的组件。比如AddBillPanel里,如果它只是记录“当前用户选了哪个分类”,这个状态没必要提升到Index页面。因为除了AddBillPanel自己,没有别的组件关心这个状态。

什么时候才提升?当多个组件需要共享同一个状态时,才提升到它们的公共父组件。这是一种“状态提升”原则,React 里这么讲,ArkTS 里也一样。

5.2 状态多了,怎么梳理数据流?

如果是稍微复杂一点的页面,我建议你在动手写代码之前,先在注释里把数据流画出来。不需要任何工具,几行注释就行:

// Index: // billData -> BillList(@Prop,只读展示) // totalExpense -> TotalSummary(@Prop,只读展示) // $billData + $totalExpense -> AddBillPanel(@Link,双向实时同步)

这样画完你会有两个发现:一是哪些组件“只读”、哪些组件“可写”,职责就清楚了;二是如果你发现一个组件既需要读、又需要写,这个组件大概率是 “状态管理中枢”,要格外小心它的修改逻辑是否会影响别的组件。

5.3 接口返回的数据,能不能直接塞进@State?

最后再补充一个实际开发中经常碰到的问题:网络请求返回的 JSON 数据,直接塞进@State数组里,页面不刷新。

这通常不是状态管理的问题,而是JSON 序列化后的对象,不是类实例。ArkTS 的@Observed只认得原本那个类,对 JSON.parse 出来的普通对象没有监听能力。

正确做法是:把 JSON 数据手动转成类实例数组再塞给状态变量。比如:

const rawList: Array<any> = JSON.parse(response); const billList: BillItem[] = rawList.map(item => { return new BillItem(item.category, item.amount, item.timestamp); }); this.billData = billList;

这个过程有点繁琐,但不是 ArkTS 刻意刁难开发者,而是因为@Observed的深层监听必须要有一个稳定的类定义作为“钩子”,纯 JSON 对象没法提供这个钩子。把普通对象映射为类实例这个习惯,能让你后面做任何接口对接都省心很多。

就我自己这段时间写 ArkTS 的感觉而言,当你真正开始用“状态”来思考页面,而不是用“操作 DOM”的思维来写代码,你的开发效率会有一个明显的飞跃。很多之前觉得绕的东西(比如$符号、@Prop单向流),用熟了以后会发现这套约束恰好让你少写了很多 bug。目前我手头几个项目已经全面切到 ArkTS 开发,状态管理这一套稳得很。

这篇先聊到这儿。记账本这个 Demo 你要是也跟着写了,可以自己试着加一个“删除账单”的交互,感受一下@Link双向绑定的细节;或者给BillItem加一个“编辑金额”的弹窗,感受一下@Observed的边界。下一期我打算聊聊网络请求和数据持久化,到时候见。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询