ArkUI 状态管理传值选型指南:@Prop、@Link、@ObjectLink、@Track 到底怎么选
ArkUI 状态管理传值选型指南:@Prop、@Link、@ObjectLink、@Track 到底怎么选
前言
在鸿蒙开发者社区里,有两类问题几乎是"日经贴"级别的高频提问。第一类是"自定义组件之间怎么传值,@Prop、@Link、@ObjectLink 到底该用哪个";第二类是"我给 @State 装饰的 ArrayList 调了 add(),数据明明加进去了,console.info 打印长度也变了,为什么列表 UI 一动不动"。
这两类问题表面上一个是"选型",一个是"Bug",但底层指向的是同一件事:ArkUI 状态管理的观察边界到底在哪里。只要把这条边界摸清楚,选型就是一道三问的选择题,而 ArrayList 不刷新则是一个必然结果,谈不上"姿势不对"。
麻烦的是,这两个问题在网上已经有大量流传甚广、但是方向错误的答案。它们大多句式整齐、看起来很有体系,但其中几条核心结论会直接把人带进沟里:
- “
@Prop是单向只读的”——错,@Prop在语法上允许子组件本地写入; - “
@Observed和@ObjectLink必须绝对配对,否则一定不刷新”——不准确,关键是有没有拿到可观察实例; - “嵌套对象想刷新,只能逐层加
@Observed再逐层拆子组件”——漏掉了 V1 里专门解决这个问题的@Track; - “
ArrayList不刷新是因为没转成数组,用 V2 的@Trace就好了”——错,@Trace同样观察不到ArrayList; - “
this.arr = this.arr会导致整棵树重绘”——错,真正决定节点是否被销毁重建的是 keyGenerator。
这篇文章就是把这五条逐一掰开。我会先讲清楚 ArkUI 观察机制的实现原理,再用同一份代码对照演示四种传值方式的行为差异,最后给出一页对照表和一套可以照着执行的选型流程。文中所有结论都以官方文档的语义为准,凡是"看起来像常识但实际有偏差"的地方,我都会明确标出正确的说法。
问题描述
先把要解决的原始诉求还原出来,避免讨论跑偏。
诉求一:自定义组件传值选型。 开发者在做一个页面,父组件持有若干状态,需要传给若干子组件。子组件的使用方式各不相同:有的只负责把数据显示出来;有的需要提供一个按钮修改这个值,并且希望父组件也跟着变;有的需要编辑一个嵌套对象里的某个字段,比如把 user.address.city 从"北京"改成"深圳";还有的只需要展示对象里的某几个属性,且希望"改哪个字段就只刷新哪一块"。面对这四种诉求,@Prop、@Link、@ObjectLink 到底该分别用在哪,是这个问题的核心。
诉求二:待办清单不刷新。 开发者用 ArrayList 存待办数据,写了这样一段代码:
@State numItems: ArrayList<string> = new ArrayList<string>()
// 点击按钮时
this.numItems.add('新事项')
点击按钮后观察到三个现象:第一,数据确实加了,console.info(this.numItems.length) 打印出来的长度变了,但列表 UI 纹丝不动;第二,尝试在 build() 里调用 this.numItems.convertToArray() 转成普通数组再交给 ForEach,依然不刷新;第三,最后只能改成 this.numItems = this.numItems,UI 终于刷新了,但感觉整个列表都被重绘了一遍,性能很差,怀疑自己写法有问题。
这两个诉求合在一起,可以还原出六条需要被验证或纠正的结论。下面我用"待验证清单"的方式列出来,后文逐条给答案:
| 编号 | 流传的说法 | 本文结论 |
|---|---|---|
| 1 | @Prop 是单向只读的,子组件不能改 | 错。单向同步 + 子组件本地可写,只是不回写父组件 |
| 2 | @Observed + @ObjectLink 必须绝对配对 | 不准确。关键是要拿到可观察实例,获取方式不止一种 |
| 3 | 嵌套对象只能逐层加 @Observed 并逐层拆子组件 | 不完整。V1 的 @Track 可以做到不拆组件、属性级刷新 |
| 4 | @State + ArrayList.add() 不刷新是因为没转成数组 | 错。根因是框架只观察内置类型的特定 API |
| 5 | 换 V2 的 @Trace 就能观察 ArrayList | 错。@Trace 同样只覆盖内置 Array/Map/Set/Date |
| 6 | 整体赋值会导致整棵树重绘,性能很差 | 错。节点是否销毁重建由 keyGenerator 决定 |
这份清单本身就是本文的骨架。接下来进入细节解析。
细节解析
一、先建立正确的心智模型:ArkUI 的观察能力是"白名单"
绝大多数"状态不刷新"的困惑,根源都是没有建立这样一个心智模型:ArkUI 的观察能力不是通用的 Java/Python 式响应式系统,而是一套基于代理包装 + 特定 API 拦截的白名单机制。
具体来说,V1 状态管理在把一个变量变成"可观察"时,会对它做代理包装,然后只拦截以下三类变化:
第一类,变量本身的整体重新赋值。 无论是基本类型还是对象引用,只要发生了 this.x = something 这种写法,都会被捕获。这是覆盖面最广、最可靠的一类观察。
第二类,内置 Array 的特定方法调用。 包括 push、pop、shift、unshift、splice、copyWithin、fill、reverse、sort,以及通过下标直接赋值(this.arr[0] = newValue)。注意这是一份明确的清单,清单之外的数组操作不会被观察。比如 delete this.arr[i](这会在数组里留下空洞)就属于清单外操作,既不可观察,语义上也不推荐。
第三类,对象第一层属性的新增、删除、修改。 关键词是"第一层"。this.user.name = '张三' 会被观察,因为 name 是 user 的第一层属性;而 this.user.address.city = '北京' 通常不会被观察,因为 city 属于第二层,代理没有递归进去。这也是为什么 V1 深度观察需要额外手段的原因。
理解了这个模型,后面所有结论都能自己推导出来。特别是那个关键推论:一个类如果不是内置类型,你调用它自己定义的方法,框架是"看不见"的。这是解开 ArrayList 之谜的钥匙。
二、纠正一:@Prop 不是"只读",它是"父组件数据的本地副本"
这是流传最广的一条错误结论。很多回答里写着"@Prop:单向,只读",甚至有回答直接说"子组件不能修改 @Prop"。这不符合官方语义。
@Prop 的准确定义是单向同步 + 子组件本地可写。拆开看两个动作:
- 同步方向是单向的:父组件的值变化时,会自动同步到子组件的
@Prop变量;但子组件里对这个变量的修改不会回写到父组件。 - 子组件本地可写:在子组件里写
this.title = 'xxx'是完全合法的,编译不报错,UI 也会跟着变。只是父组件处于"权威"地位——父组件下次重新赋值时,子组件的本地修改会被直接覆盖掉。
所以 @Prop 的正确定位是"父组件数据的一份本地副本",而不是"不可变常量"。如果你想在子组件里做临时编辑、草稿态这类需求,@Prop 恰好合适:既能改,又不会污染父组件,父组件一更新就自然重置。真正需要靠回调显式提交的场景,才需要额外加 @Event 或回调函数。
还有一个与 @Prop 语义相反、极易记混的装饰器:@ObjectLink。@ObjectLink 在语法上是禁止整体赋值的——this.address = new Address('上海', '南京路') 会直接抛运行时错误,它能做的只有修改属性 this.address.city = '深圳'。两者的限制方向刚好相反,一个"可以整体写但不能回写父组件",一个"不能整体写但能改属性",千万别记混。
再说一个容易被忽视的成本问题:@Prop 对对象类型是深拷贝。传一次对象就是一次完整克隆。如果是大对象或者长数组,父组件又频繁更新,这份克隆开销会非常直接地体现在滑动掉帧上。这种场景下要么改用 @Link 共享引用,要么让子组件只接收它真正需要的字段。
三、纠正二:@Observed + @ObjectLink 不是"绝对配对"
“两者必须配对,否则对象不会被观察”——这个说法在最朴素的场景下成立,但把它当成绝对真理就过严了。更准确的表述是:
@ObjectLink需要一个可观察的实例。而获得可观察实例的方式不止一种。
用 @Observed 装饰 class,是最常规、最推荐的一种;makeV1Observed() 手动包装出来的实例同样可以被 @ObjectLink 接收;在较新的 SDK 上,@ObjectLink 的初始化类型限制也有所放宽(API 19 之后放开了部分初始化类型约束)。所以"不配对就一定不刷新"这种断言,只在"你随手 new 了一个普通类的实例传进去"这种最朴素的场景里成立。
但有一条方向是死的,必须牢记:@Observed 只能装饰 class,不能装饰 interface。
这条约束的杀伤力远比它看起来大。很多开发者习惯用 interface 来定义数据模型——这在纯 TypeScript 里是完全合理的写法——然后在 @ObjectLink 上遇到"怎么改都不刷新"的诡异现象。原因就是 interface 在编译后是不产生运行时构造函数的,框架没有东西可以包装,装饰器加不上去。所以只要数据模型需要被观察,从第一天起就要用 class 定义,并且给出带默认值的字段和构造函数。
顺带说一个高频误判:@Observed 加上之后,是不是就"自动深度观察"了?不是。只给最外层加 @Observed,第二层及以下依然观察不到。 想让 user.address.city 的变化可见,Address 这个类也必须具备可观察性——要么加 @Observed 然后由子组件用 @ObjectLink 接收,要么加 @Track(见下一节),要么走 V2。
四、补上被严重低估的 V1 @Track
这是整篇文章里最值得记住的一点。面对"嵌套对象要精确刷新"的需求,网上给出的标准答案是:每一层都加 @Observed,然后逐层拆出子组件,每层用 @ObjectLink 接收。这个方案是对的,但工程量大、样板代码多,嵌套三层以上就非常难受。
其实 V1 里有一个专门的装饰器来解决"属性级精确刷新":@Track。
这里必须澄清一个概念差异,否则会用错:@Track 是"类属性装饰器",不是"组件成员装饰器"。 @Prop、@Link、@ObjectLink 都写在组件的成员变量上,而 @Track 是写在 class 的属性上的。你给 class 的某个属性加上 @Track,再让这个 class 的实例被 @State、@Prop 或 @Link 持有,就能得到这样一个效果:只有被 @Track 标记的属性发生变化时才触发刷新,并且可以在同一个组件里直接渲染嵌套属性,不需要为了刷新去拆子组件。
class Address {
@Track city: string = ''
@Track street: string = ''
constructor(city: string, street: string) {
this.city = city
this.street = street
}
}
class User {
@Track name: string = ''
// address 本身是对象,这里的 @Track 标记的是 address 这个属性的"整体替换"
// address 内部的 city 变化能否刷新,取决于 Address 的属性是否被 @Track 标记
@Track address: Address = new Address('北京', '长安街')
constructor(name: string) {
this.name = name
}
}
@Entry
@Component
struct ProfilePage {
@State user: User = new User('张三')
build() {
Column({ space: 8 }) {
Text(`姓名:${this.user.name}`)
Text(`城市:${this.user.address.city}`)
Button('改城市(深层属性)')
.onClick(() => {
// Address.city 被 @Track 标记,直接改深层属性即可刷新
this.user.address.city = '深圳'
})
Button('整体换地址')
.onClick(() => {
this.user.address = new Address('上海', '南京路')
})
}
.width('100%')
.padding(20)
}
}
@Track 有两个陷阱必须记住。
陷阱一:它的语义是"精确追踪",没标记就不刷新。 如果你只给 name 和 city 加了 @Track,那么 this.user.address.street = 'x' 就不会触发刷新。这不是 Bug,而是它的设计目标——用最少的刷新换取最精确的渲染。所以加 @Track 时,要把该对象下所有需要驱动 UI 的字段都覆盖完整。
陷阱二:@Track 与 @Observed 的定位完全不同,不要互相替代。 @Observed 解决的是"对象被跨组件传递后,子组件如何收到属性变化",所以它必须配 @ObjectLink 使用,面向的是组件边界;@Track 解决的是"在同一个观察范围内,哪些属性变化才值得刷新",面向的是刷新粒度。两者可以叠加使用,也可以只用其中一种。理解了这层差异,"到底该用 @Observed 还是 @Track"这个问题就自动消失了——它们回答的根本不是同一个问题。
补充一句版本边界:@Track 只在 V1 有效。 V2 的对应能力是 @ObservedV2 类 + @Trace 属性。@Trace 直接写在类定义里,不依赖 @ObjectLink 的组件拆分,作用域上相当于把 @Track 和 @Observed 的职责合并了。新项目优先选 V2 会更省事。
五、@State + ArrayList 不刷新的真正原因
现在回到第二个诉求。有了第一节的心智模型,这个问题其实是可以直接推导出来的。
ArrayList 是 @kit.ArkTS(kit 化之前的写法是 @ohos.util)提供的线性容器类。它内部自己维护 elementData 之类的缓冲数组,add() 是它自己定义的普通方法。框架的代理拦截器根本不在这条调用链上——它拦截的是内置 Array 的 push、splice 这批方法,而 ArrayList.add() 是谁都不认识的一个自定义方法。
所以结论非常干脆:@State numItems: ArrayList<string> 里调用 add(),UI 不刷新是必然的,不是"你用错了姿势"。 这是框架观察白名单的必然结果。
接下来把楼主观察到的另外两个现象也解释清楚,因为它们各自还藏着一个更值得警惕的坑。
现象一:console.info(this.numItems.length) 变了。 这只能说明 JS 层的数据结构确实变了,跟 UI 是否刷新没有任何关系。状态变量能不能被观察到,取决于"变化是否通过了框架的拦截通道",而不取决于"数据是否真的变了"。很多人在调试状态问题时把 console 输出当成"状态已生效"的证据,这是最常见的误判来源之一。
现象二:在 build() 里调 convertToArray() 无效。 表面上这是"因为 build 没重跑,所以转换逻辑没执行",但更值得警惕的是另一半:每次 build 重跑,convertToArray() 都会生成一个全新的数组对象。 如果你还把这个临时数组作为 @Prop 或 @Link 传给子组件,每次父组件重绘都会产生一个新引用,子组件就会跟着反复刷新。所以"放在 build 里转换"不是"没用",而是"稳定地制造额外刷新"——一个更难排查的性能陷阱。
正确做法有三条路,按优先级排列:
第一条,也是绝大多数场景的正解:直接换成原生数组。 如果只是想要一个"有顺序、能按索引访问、能增删"的列表,原生 Array 完全够用,而且 push、splice、下标赋值都在白名单内,天然支持增量刷新。
第二条,确实需要容器类的特有 API 时(比如 ArrayList 的某些查找、插入语义),做分层:把容器限制在数据层,UI 层只暴露被观察的原生数组,并且只在一个地方做同步动作,绝对不要放在 build() 里。
第三条,避免的写法:不要为了绕过不刷新就把 ArrayList 改成 any 或 Object。那既解决不了刷新问题,又会直接踩上 ArkTS 的 arkts-no-any-unknown 约束,编译都过不去。
另外要提醒一个浪费时间的伪解法:import { ArrayList } from '@kit.ArkTS' 和 from '@ohos.util' 只是 kit 化前后两种导包写法,指向同一套实现。改导包写法并不会让 add() 变得可观察。不少人在这一步白折腾了很久。
六、纠正三:@Trace 也救不了 ArrayList
网上有一条看起来很有道理的修复建议:"用 @State 观察不了容器类,那就上 V2 的 @ObservedV2 + @Trace。"这个建议是错的,而且错得很有代表性。
@Trace 能观察两类变化:一是被标记属性的整体赋值;二是当被标记属性的类型是内置的 Array、Map、Set、Date 时,这些内置类型的 API 调用(push、splice、set、delete、Date.setTime 等)。
关键就在"内置的"这三个字。ArrayList 只是一个自定义类,它不在这个内置类型清单里。所以 @Trace items: ArrayList<string> 配合 this.items.add(x),依然不会刷新——和 V1 是同一个原因,同样的失败机制。换汤不换药。
那 V2 相对 V1 的实质改进到底在哪里?答案很明确:Map / Set 的 API 调用变得可观察了。 V1 阶段只对 Array 提供了 API 级观察,对 Map / Set 基本只能靠整体重新赋值;V2 把 Map、Set 也纳入了内置观察清单。
所以选型的判断标准应该是语义匹配,而不是"版本越新越好":
- 如果你的业务本质就是"要
Map/Set的语义"(键值查找、去重),那么直接上 V2 + 内置Map/Set确实是正解,@Trace能观察它们的set/delete; - 如果你只是想要一个带索引能力的列表,那么原生
Array在 V1 里就已经够用,没有必要为了用Map而引入 V2; - 如果你真的需要
ArrayList这类容器的特定 API,那么无论 V1 还是 V2,都必须在 UI 层做数据同步,装饰器版本帮不了你。
七、纠正四:整体赋值 ≠ 整棵树重绘,keyGenerator 才是决定因素
最后一条,“this.numItems = this.numItems 会导致整棵树重绘”——这个说法不准确,而且会误导人做出错误的优化决策。
this.numItems = this.numItems 触发的动作是:依赖这个状态变量的组件重新执行 build()。注意,重新执行 build() 和"销毁所有节点再重建"是两件完全不同的事。build() 重跑之后,ForEach 会拿着新旧两个列表,按 key 做差量比对。key 稳定且能命中的节点,是被复用的,不会走"销毁 + 新建"的昂贵路径。
那么,真正会让节点被销毁重建的是什么?是你的 keyGenerator。
ForEach 的第三个参数(keyGenerator)默认会由索引和元素内容拼出一个 key。内容一变,key 就变,框架找不到能复用的旧节点,只能把旧的删掉、新建一个。对待办列表这种会增删、会移动的数据,"内容参与 key"是灾难性的。
最容易踩的坑是用"标题文本"当 key。用户添加两条同名待办,key 就直接冲突了,UI 会出现难以复现的错乱。第二容易踩的是用数组下标当 key——一旦发生删除或插入,后续所有项的下标都会平移,key 全部错位,表现就是"明明只删了一行,下面所有行的状态都跟着串位"。
正确做法只有一条:用稳定的业务 id 做 key。 而且这个 id 必须在数据创建时就生成好(比如用一个自增序号或时间戳 + 序号),跟着数据走完整个生命周期。用稳定的业务 id 做 key,才是"整体重新赋值也不卡"的前提。 换句话说,"整体赋值会不会导致重绘"这个问题的答案,不在赋值语句本身,而在你的 keyGenerator 写得对不对。
八、一页对照表
把上面所有结论压缩成一张表,方便收藏对照:
| 装饰器 / 机制 | 适用数据 | 同步方向 | 子组件可写性 | 观察深度 | 典型场景 |
|---|---|---|---|---|---|
@Prop | 基本类型、对象(深拷贝) | 父 → 子 单向 | 可本地写,不回写父组件,父组件更新时被覆盖 | 仅父组件重新赋值时同步 | 子组件展示型场景、临时编辑草稿 |
@Link | 基本类型、数组、简单对象 | 父 ↔ 子 双向,共享同一引用 | 可写,会回写父组件 | 仅变量整体被重新赋值 | 父子共同修改同一份状态 |
@ObjectLink | @Observed 装饰的 class 实例 | 父 → 子 引用传递,属性变化双向可见 | 不可整体赋值,只能改属性 | 属性级(限于被 @Observed 装饰的那一层) | 数组中的对象项编辑、跨组件属性级同步 |
@Track(类属性) | 被 @State/@Prop/@Link 持有的 class | 随宿主装饰器 | 随宿主装饰器 | 属性级精确追踪,未标记的属性不触发刷新 | 不拆组件即可实现嵌套对象局部刷新 |
V2 @Trace(类属性) | @ObservedV2 类的属性 | 随宿主装饰器 | 随宿主装饰器 | 属性级 + 内置 Array/Map/Set/Date 的 API 调用 | 新项目的嵌套 / 数组场景,Map/Set 语义 |
原生 Array | T[] | — | — | push/splice/下标赋值等白名单方法可观察 | 列表、待办等绝大多数数组场景 |
ArrayList 等容器类 | @kit.ArkTS 容器 | — | — | 不可观察,任何装饰器都救不了 | 仅限数据层,UI 层需手动同步为原生数组 |
| keyGenerator | ForEach 第三参 | — | — | 决定节点复用还是销毁重建 | 必须返回稳定唯一的业务 id |
九、可以照着执行的选型流程
第一步:子组件需不需要把修改回传给父组件?
- 不需要 → 用
@Prop。但要清楚它对对象做的是深拷贝。大对象、长数组别用@Prop,传一次就是一次完整克隆,父组件频繁更新时开销很直接。 - 需要 → 进入第二步。
第二步:数据是基本类型还是对象 / 数组?
- 基本类型(
string、number、boolean、enum)→ 用@Link。父子共享同一份,子改父改都会互相触发。 - 对象 / 数组 → 进入第三步。
第三步:是"整个对象一起换",还是"只改对象里的某个属性"?
- 整个对象一起换 →
@Link就够。注意类型必须与父组件的@State变量完全一致,@Link不做拷贝,改的是同一份引用;父组件传参时用$语法或在较新 SDK 上直接传引用。 - 只改属性 → 两个选择:
- 给 class 的属性加
@Track,不拆组件,在同一个组件里直接渲染嵌套属性,实现属性级刷新; - 用
@Observed+@ObjectLink,拆到子组件里做跨组件的属性级同步。
- 给 class 的属性加
第四步:数组里的对象项需要在子组件里编辑吗?
- 需要 → 几乎只能走
@Observed+@ObjectLink。因为@Track解决的是"同一观察范围内的刷新粒度",跨组件传递数组项时,子组件仍然需要@ObjectLink来接收可观察实例。 - 不需要 → 原生数组 +
@State+ 稳定的item.id作为 key,就是最优解。
第五步(兜底经验):如果数据是"多层嵌套 + 数组 + 需要局部编辑",就别在 V1 里硬撑了。
V1 默认只观察第一层、每层都要拆组件、还要维护 @ObjectLink 的不可整体赋值约束,改动量会随嵌套层数线性增长。V2 的 @ObservedV2 + @Trace 是属性级观察,嵌套和数组都能覆盖,这才是这类需求的正解。
十、V1 与 V2 的混用禁忌,以及版本差异
最后补两个工程上一定会撞到的坑。
禁忌一:V1 装饰器和 V2 装饰器不能混用在同一个组件里。 @Component + @State / @Prop / @Link 属于 V1;@ComponentV2 + @Local / @Param / @Event / @Provider / @Consumer 属于 V2。两套不能交叉声明在同一个 struct 里。同一个工程里可以让不同组件分别使用两套(V1 和 V2 可以在同一工程共存的),但不要试图在同一个结构体里混着写。迁移期最常见的编译报错就来自这里。
禁忌二:@Track 与 @Observed 混用时要想清楚职责。 如果给一个 class 同时加了 @Observed 又在属性上加了 @Track,那么"哪些属性值得刷新"由 @Track 决定,"这个实例能不能被 @ObjectLink 接收"由 @Observed 决定。两者叠加是合法的,但你要能说清每一层的意图,否则很容易出现"某个字段怎么改都不刷新"的现象。
版本差异上还有两个常被忽略的点。 一是 @Require(父组件必须传值,否则编译报错)在较新的 SDK 上才可用,老工程里只能靠 @Prop 的默认值兜底。二是 @Prop 对 undefined / null 的支持范围、以及 @ObjectLink 初始化类型的放宽,都随 SDK 版本变化——跨版本升级时那些"昨天还能编译、今天直接报错"的问题,基本都出在这里。所以升级 SDK 之后,请优先把状态管理相关的编译告警清一遍,而不是等运行时发现某个 Text 不刷新。
示例代码
下面的代码分成两组。第一组把 @Prop、@Link、@ObjectLink、@Track 放在同一个页面里对照演示,方便直接跑起来观察四者的行为差异。第二组是 ArrayList 不刷新与修复后的完整对比 Demo,包含勾选、删除、计数三个交互。
第一组:四种传值方式同页对照
先定义数据模型。注意 Address 用 class 定义(因为 @Observed 只能修饰 class,interface 加不上装饰器),并且给 Score 的属性加 @Track。
// 模型一:@Track 属性级刷新(不需要 @Observed,也不需要拆子组件)
class Score {
@Track value: number = 0
@Track level: string = 'C'
constructor(value: number) {
this.value = value
this.level = Score.calcLevel(value)
}
static calcLevel(value: number): string {
if (value >= 90) {
return 'A'
}
if (value >= 60) {
return 'B'
}
return 'C'
}
}
// 模型二:@Observed 让实例可被 @ObjectLink 接收
@Observed
class Address {
city: string = ''
street: string = ''
constructor(city: string, street: string) {
this.city = city
this.street = street
}
}
四个子组件,分别演示四种语义:
// 1) @Prop:父 → 子单向同步 + 子组件本地可写(不回写父组件)
@Component
struct PropCard {
@Prop title: string = ''
@Prop tags: string[] = []
build() {
Column({ space: 6 }) {
Text(`@Prop 收到:${this.title}`)
Text(`数组副本长度:${this.tags.length}`)
Button('本地改名(不回写父组件)')
.onClick(() => {
// 合法:@Prop 允许本地写入,UI 会变,但父组件不受影响
this.title = `子组件本地值-${Date.now() % 1000}`
})
Button('改本地数组副本(父组件更安全)')
.onClick(() => {
// 改的是 @Prop 的深拷贝副本,父组件的数组不受影响
this.tags.push('local-only')
})
}
.padding(12)
.borderRadius(8)
.backgroundColor('#EEF4FF')
}
}
// 2) @Link:父 ↔ 子双向,共享同一份引用
@Component
struct LinkCounter {
@Link count: number
build() {
Row({ space: 8 }) {
Text(`@Link 子组件:${this.count}`)
Button('+1(回写父组件)')
.onClick(() => {
this.count += 1
})
Button('×2(回写父组件)')
.onClick(() => {
this.count = this.count * 2
})
}
.padding(12)
.borderRadius(8)
.backgroundColor('#EFFBF1')
}
}
// 3) @ObjectLink:接收 @Observed 实例,可改属性、不可整体赋值
@Component
struct ObjectLinkEditor {
@ObjectLink address: Address
build() {
Column({ space: 6 }) {
Text(`@ObjectLink 城市:${this.address.city}`)
Text(`@ObjectLink 街道:${this.address.street}`)
Button('改城市(属性级,双向可见)')
.onClick(() => {
// 允许:修改属性
this.address.city = '深圳'
})
Button('整体赋值(会抛异常,演示用)')
.onClick(() => {
// 下面这行如果放开会直接报错:
// this.address = new Address('上海', '南京路')
// @ObjectLink 禁止整体赋值,只能改属性
this.address.street = '深南大道'
})
}
.padding(12)
.borderRadius(8)
.backgroundColor('#FFF4EC')
}
}
// 4) @Track:同一个组件内直接渲染嵌套属性,不拆组件也能属性级刷新
@Component
struct TrackPanel {
@Link score: Score
build() {
Column({ space: 6 }) {
Text(`@Track value:${this.score.value}`)
Text(`@Track level:${this.score.level}`)
Button('只改 value(level 同步更新)')
.onClick(() => {
this.score.value += 10
this.score.level = Score.calcLevel(this.score.value)
})
}
.padding(12)
.borderRadius(8)
.backgroundColor('#F3EEFF')
}
}
父页面把四者组装起来。注意 @Link 传参的写法,以及 @ObjectLink 传的是 @State 里对象的引用。
@Entry
@Component
struct PropLinkComparePage {
@State title: string = '父组件标题'
@State tags: string[] = ['arkui', 'harmonyos']
@State count: number = 0
@State userAddress: Address = new Address('北京', '长安街')
@State score: Score = new Score(50)
build() {
Scroll() {
Column({ space: 14 }) {
Text('第一部分:@Prop 单向 + 本地可写')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.width('100%')
Text(`父组件当前 title:${this.title}`)
Text(`父组件 tags:${this.tags.join(' / ')}`)
PropCard({ title: this.title, tags: this.tags })
Button('父组件重设 title(会覆盖子组件本地修改)')
.onClick(() => {
this.title = `父组件标题-${Date.now() % 1000}`
})
Divider()
Text('第二部分:@Link 双向共享')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.width('100%')
Text(`父组件当前 count:${this.count}`)
LinkCounter({ count: this.count })
Button('父组件 +100')
.onClick(() => {
this.count += 100
})
Divider()
Text('第三部分:@Observed + @ObjectLink 属性级双向')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.width('100%')
Text(`父组件看到的地址:${this.userAddress.city} ${this.userAddress.street}`)
ObjectLinkEditor({ address: this.userAddress })
Button('父组件整体换地址')
.onClick(() => {
// 父组件走的是整体赋值,重新生成可观察实例
this.userAddress = new Address('杭州', '西湖大道')
})
Divider()
Text('第四部分:@Track 不拆组件属性级刷新')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.width('100%')
TrackPanel({ score: this.score })
Button('父组件直接改 score.value')
.onClick(() => {
// Score.value 被 @Track 标记,父组件侧直接改也能触发刷新
this.score.value += 5
this.score.level = Score.calcLevel(this.score.value)
})
}
.width('100%')
.padding(20)
}
.width('100%')
.height('100%')
}
}
把这段代码跑起来,可以直观验证三件事:@Prop 的本地修改确实生效但父组件不变;@Link 的子改父确实回写;@ObjectLink 只能改属性不能整体赋值;@Track 在完全不拆子组件的前提下实现了属性级刷新。
第二组:ArrayList 不刷新 → 修复后对比
修复前(错误示范):@State 持有 ArrayList,所有增删改都调容器自己的方法,UI 永远不动。
import { ArrayList } from '@kit.ArkTS'
interface TodoItem {
id: string
text: string
done: boolean
}
@Entry
@Component
struct TodoBrokenPage {
// ArrayList 不是内置类型,@State 观察不到它的任何方法调用
@State items: ArrayList<TodoItem> = new ArrayList<TodoItem>()
private seq: number = 0
build() {
Column({ space: 12 }) {
Row() {
Text(`共 ${this.items.length} 项,已完成 ${this.doneCount()} 项`)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Blank()
Button('添加')
.onClick(() => {
this.seq++
const item: TodoItem = {
id: `todo_${this.seq}`,
text: `新事项 ${this.seq}`,
done: false
}
// 数据确实加进去了,但 UI 不会刷新
this.items.add(item)
})
}
.width('100%')
List({ space: 8 }) {
ForEach(this.items.convertToArray(), (item: TodoItem, index: number) => {
ListItem() {
Row({ space: 8 }) {
Checkbox({ name: item.id, group: 'broken' })
.select(item.done)
.onChange(() => {
const target: TodoItem | undefined = this.items[index]
if (target !== undefined) {
target.done = !target.done
// 元素内部属性变化同样观察不到
}
})
Text(item.text)
.fontSize(16)
.layoutWeight(1)
Button('删除')
.fontSize(14)
.onClick(() => {
// removeByIndex 同样是容器自己的方法,不触发刷新
this.items.removeByIndex(index)
})
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor('#F5F5F5')
}
}, (item: TodoItem) => item.id)
}
.layoutWeight(1)
.width('100%')
Text('当前写法:点击添加/删除后 UI 不会变化,这是框架观察白名单的必然结果')
.fontSize(12)
.fontColor('#C0392B')
}
.width('100%')
.height('100%')
.padding(20)
}
private doneCount(): number {
let count: number = 0
for (let i = 0; i < this.items.length; i++) {
const current: TodoItem | undefined = this.items[i]
if (current !== undefined && current.done) {
count++
}
}
return count
}
}
修复后(推荐写法):UI 层只持有原生数组,所有增删改走白名单方法,key 用稳定的业务 id。
interface Todo {
id: string
text: string
done: boolean
}
@Entry
@Component
struct TodoFixedPage {
// 原生数组:push / splice / 下标赋值都在观察白名单里
@State todoList: Todo[] = []
private seq: number = 0
build() {
Column({ space: 12 }) {
Row() {
Text(`共 ${this.todoList.length} 项,已完成 ${this.doneCount()} 项`)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Blank()
Button('添加')
.onClick(() => {
this.seq++
const item: Todo = {
id: `todo_${this.seq}`,
text: `新事项 ${this.seq}`,
done: false
}
// push 可被 @State 观察,只有新增项会被创建,已有项复用
this.todoList.push(item)
})
}
.width('100%')
List({ space: 8 }) {
ForEach(this.todoList, (item: Todo, index: number) => {
ListItem() {
Row({ space: 8 }) {
Checkbox({ name: item.id, group: 'fixed' })
.select(item.done)
.onChange(() => {
// 元素内部属性变化 @State 观察不到,这里整体替换元素
this.todoList.splice(index, 1, {
id: item.id,
text: item.text,
done: !item.done
})
})
Text(item.text)
.fontSize(16)
.decoration({
type: item.done
? TextDecorationType.LineThrough
: TextDecorationType.None
})
.layoutWeight(1)
Button('删除')
.fontSize(14)
.onClick(() => {
// splice 在观察白名单内,UI 增量刷新
this.todoList.splice(index, 1)
})
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor('#F5F5F5')
}
}, (item: Todo) => item.id) // 稳定唯一 key,不要用 index,也不要用文本内容
}
.layoutWeight(1)
.width('100%')
Button('清空已完成')
.width('100%')
.onClick(() => {
// 过滤出新数组后整体赋值,配合上面的 key,节点会被复用而非全量重建
this.todoList = this.todoList.filter((item: Todo) => !item.done)
})
Text('当前写法:添加、勾选、删除、清空均能正确刷新,且 key 稳定时节点复用')
.fontSize(12)
.fontColor('#1E8449')
}
.width('100%')
.height('100%')
.padding(20)
}
private doneCount(): number {
let count: number = 0
for (let i = 0; i < this.todoList.length; i++) {
if (this.todoList[i].done) {
count++
}
}
return count
}
}
第三组:必须保留 ArrayList 时的分层写法
如果业务真的依赖 ArrayList 的特有 API,那就把它严格限制在数据层,UI 层只暴露被观察的原生数组。这一层封装的成本,远低于在 UI 层和容器类较劲。
import { ArrayList } from '@kit.ArkTS'
interface TodoEntry {
id: string
text: string
done: boolean
}
class TodoRepository {
private container: ArrayList<TodoEntry> = new ArrayList<TodoEntry>()
add(item: TodoEntry): void {
this.container.add(item)
}
removeAt(index: number): void {
this.container.removeByIndex(index)
}
replaceAt(index: number, item: TodoEntry): void {
this.container.removeByIndex(index)
this.container.insert(item, index)
}
// 对外暴露的是普通数组快照,调用方负责同步到 @State
snapshot(): TodoEntry[] {
return this.container.convertToArray()
}
size(): number {
return this.container.length
}
}
页面侧只在一个地方做同步动作,而这个动作是主动触发的,不是放在 build() 里的:
@Entry
@Component
struct TodoRepoPage {
@State items: TodoEntry[] = []
private repo: TodoRepository = new TodoRepository()
private seq: number = 0
private addTodo(text: string): void {
this.seq++
const item: TodoEntry = {
id: `t_${this.seq}`,
text: text,
done: false
}
// 两条路各走各的:UI 层走增量刷新,数据层保留容器能力
this.items.push(item)
this.repo.add(item)
}
private removeTodo(index: number): void {
this.items.splice(index, 1)
this.repo.removeAt(index)
}
private toggleTodo(index: number): void {
const current: TodoEntry = this.items[index]
const next: TodoEntry = {
id: current.id,
text: current.text,
done: !current.done
}
this.items.splice(index, 1, next)
this.repo.replaceAt(index, next)
}
private reloadFromRepo(): void {
// 需要与数据层重新对齐时,一次性同步,绝不放进 build()
this.items = this.repo.snapshot()
}
build() {
Column({ space: 12 }) {
Row() {
Text(`UI 层 ${this.items.length} 项 / 数据层 ${this.repo.size()} 项`)
.fontSize(15)
Blank()
Button('添加')
.onClick(() => {
this.addTodo(`来自 Repo 的事项 ${this.seq + 1}`)
})
Button('与数据层对齐')
.onClick(() => {
this.reloadFromRepo()
})
}
.width('100%')
List({ space: 8 }) {
ForEach(this.items, (item: TodoEntry, index: number) => {
ListItem() {
Row({ space: 8 }) {
Checkbox({ name: item.id, group: 'repo' })
.select(item.done)
.onChange(() => {
this.toggleTodo(index)
})
Text(item.text)
.fontSize(16)
.layoutWeight(1)
Button('删除')
.fontSize(14)
.onClick(() => {
this.removeTodo(index)
})
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor('#F5F5F5')
}
}, (item: TodoEntry) => item.id)
}
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
.padding(20)
}
}
注意 reloadFromRepo() 这个方法的位置:它是被按钮显式触发的,而不是写在 build() 里。这就是"在 build() 里转换"和"在事件里同步"的本质差别——后者是可控的一次刷新,前者是每次重绘都制造新引用。
总结
把全文结论收拢成六句话,可以直接作为日常开发的自检清单。
第一,@Prop 的准确定位是"父组件数据的本地副本",不是"只读常量"。 子组件在语法上可以写 this.title = xxx,UI 会变更,只是不回写父组件,并且父组件下次赋值时会覆盖这个本地修改。与之相对,真正禁止整体赋值的是 @ObjectLink——this.address = new Address(...) 会直接报错。两者限制方向相反,不要记混。还要记住 @Prop 对对象做深拷贝,大对象、长数组慎用。
第二,@Observed 与 @ObjectLink 的关键不是"配对仪式",而是"拿到可观察实例"。 @Observed 装饰 class 是最常规的方式,makeV1Observed() 的返回值同样可用,较新 SDK 上 @ObjectLink 的初始化类型也放宽了。但有一条是死的:@Observed 只能修饰 class,不能修饰 interface。数据模型必须用 class 定义,并给出字段默认值和构造函数。
第三,不必为了深层刷新逐层拆组件——V1 里还有 @Track。 它是"类属性装饰器",不是"组件成员装饰器"。给 class 属性加 @Track,再让实例被 @State / @Prop / @Link 持有,就能在同一个组件里直接渲染嵌套属性,实现属性级精确刷新。要记住它的语义是"没标记就不刷新",以及它与 @Observed 的职责差异:前者面向刷新粒度,后者面向组件边界。
第四,@State + ArrayList.add() 不刷新是必然结果,不是姿势问题。 ArkUI 的观察能力是一份白名单:变量整体赋值、内置 Array 的特定方法、对象第一层属性。ArrayList 是 @kit.ArkTS 提供的容器类,它的 add() 不在任何一条拦截链上。console.info 打印长度变了只能证明 JS 层数据变了,与 UI 刷新无关;在 build() 里 convertToArray() 更危险,因为每次重绘都会生成新数组引用,会稳定地制造额外刷新。正确做法是换成原生数组,或者把容器限制在数据层、只在一个显式事件里同步。
第五,@Trace 也不是 ArrayList 的解药。 @Trace 只覆盖内置的 Array、Map、Set、Date。V2 相对 V1 的实质改进是 Map / Set 的 API 调用变得可观察,而不是"能观察任意自定义类"。选型要按语义匹配:要 Map/Set 语义就上 V2 + 内置类型,要索引列表原生 Array 就够,要容器特有 API 就必须做数据层同步。
第六,"整体赋值 = 整棵树重绘"这个说法不准确,真正决定销毁重建的是 keyGenerator。 整体赋值触发的是依赖该状态的组件重跑 build(),ForEach 随后按 key 做差量比对,key 命中的节点是被复用的。用"标题文本"当 key 会导致同名待办冲突,用数组下标当 key 会在删除插入时全线错位。用稳定的业务 id 做 key,才是整体赋值也不卡的前提。
最后一条工程经验:V1 和 V2 装饰器不能混用在同一个组件里,@Component + @State 与 @ComponentV2 + @Local / @Param 是两套体系,迁移期最常见的编译报错都来自这里。同一个工程可以让不同组件分别使用两套,但不要试图在同一个 struct 里混着写。
如果你的数据形态是"多层嵌套 + 数组 + 需要局部编辑",那么不要在 V1 里硬撑。V1 默认只观察第一层、每层都要拆组件、还要维护 @ObjectLink 不可整体赋值的约束,改动量会随嵌套层数线性增长。这种场景下,V2 的 @ObservedV2 + @Trace 才是正解——它是属性级观察,嵌套和数组都能覆盖,而且不需要为了刷新去拆组件。
更多推荐

所有评论(0)