刚接触 ArkTS 的人,十有八九会被那一堆装饰器劝退。我最初也一样,觉得它不是让我写 UI,而是在教我做“依赖分析”。等真的把一个页面从命令式改造成声明式之后,才发现这套写法真正的价值:UI 只是状态的投影,状态一变,界面会自己跟着变。这篇我从零拆 ArkTS 声明式语法和状态管理,适合刚入门、或者想系统理解 @State/@Prop/@Link 区别的开发同学,读完至少能少踩一半常见的刷新坑。
1. 先搞懂声明式语法:UI 不是“做”出来的,是“算”出来的
1.1 命令式界面开发为什么越写越累
早期写界面,最典型的一套流程是:先在布局文件里定义控件,然后在逻辑代码里拿着控件实例,手动设置内容、显示或隐藏。比如一个登录页,输入框有没有内容、登录按钮能不能点、错误提示显示什么,这三个状态互相影响,我得在多个回调里分别调用setText、setEnabled、setVisibility。一旦需求加一个“密码框为空时提示不同文案”,就要再找几个地方补逻辑。
这种写法的痛点在于:UI 的状态被人为地拆散到了不同代码块里,改一个业务规则,必须在所有涉及的地方同步修改,漏一处就是线上 bug。后来出现了声明式 UI,核心思想非常朴素:把界面看成状态的函数,UI = f(state)。你只需要描述“状态长什么样时,界面长什么样”,剩下的刷新、对比、更新交给框架。
ArkTS 的声明式语法走的也是这条路。你在build()方法里用Column、Row、Text、Button这些组件搭出页面结构,用if、ForEach表达条件和列表,再通过状态变量驱动它们。当某个被装饰器标记的变量变化时,框架会自动找到依赖它的组件并刷新,不再需要手动去改控件。
1.2 ArkTS 和 TypeScript:戴着安全带的动态语言
刚看名字会以为 ArkTS 就是 TypeScript 换了个名字,实际体验下来,它更像是 TS 的一个安全子集。TypeScript 本身允许很多灵活的动态写法,但 ArkTS 为了支持 UI 框架在编译期分析状态依赖,做了一些约束,比如弱化any,要求成员变量在使用前有明确类型,限制部分动态反射能力。
对 UI 层面来说,这些约束其实是好事。比如定义数据模型,我会用一个普通的class或interface:
interface Task { id: number; title: string; done: boolean; }强类型能让编辑器提前告诉你哪里类型对不上,也能让状态管理在传递数据时有个清晰的边界。入门阶段不需要把 TypeScript 的全部特性学完,先掌握interface、class、基本类型、数组方法、箭头函数就够了。后面写组件参数、处理列表数据,都会反复用到。
1.3 装饰器:声明式语法的“魔法入口”
ArkTS 里最显眼的语法元素就是装饰器,比如@Component、@State、@Prop、@Link。很多新手会误以为它只是给类加个标记,实际上它是整个状态管理系统的“接线口”。
以@State为例,它修饰一个成员变量后,这个变量就变成了可观察的 UI 状态。框架会记录:在build()过程中,哪些组件读取过这个变量;当变量重新赋值时,框架只通知这些组件刷新。如果没有装饰器,普通成员变量变了,UI 不会有一丝反应。
我习惯把装饰器理解成“黑板上的公告”和“便签纸”的区别。普通变量是便签,贴在自己桌上,改不改没人关心;@State是黑板,写了什么全办公室都看得到。你写@State不是为了炫技,而是明确告诉 UI 框架:这个值值得被关注,变化时必须通知界面。
2. 状态管理入门:先给状态分分类
2.1 状态不是数据库,而是 UI 的数据源
状态管理听起来很玄,实际上就一句话:状态是那些“变了之后,界面要跟着变”的数据。包括业务数据,比如用户列表、详情内容;也包括视图状态,比如当前选中的 Tab、是否加载中、筛选项。
有一个常见误区:刚接触 ArkTS,看到装饰器就想给所有变量都加上,把数据全部变成状态。结果界面频繁刷新,性能变差,定位问题也变得很困难。我的原则是,写变量之前先问自己一句:这个值变了,UI 会变吗?如果不会,就让它当普通字段或外部变量;如果会,才考虑用状态装饰器。
比如一个页面只展示用户名,用户名本身肯定要放在状态里,因为网络请求后它的变化要反映到界面。但一个临时计算用的中间变量,就不要加@State,否则无端增加更新负担。
2.2 @State 与组件内状态的最小闭环
@State是每个组件自己内部的“私有状态”,最典型的例子是计数器或展开收起标记。看一段最小示例:
@Component struct Counter { @State count: number = 0; build() { Row() { Button('-') .onClick(() => { this.count--; }) Text(`${this.count}`) Button('+') .onClick(() => { this.count++; }) } } }这里不需要像传统框架那样调用setState()或update(),直接对this.count赋值即可。因为编译阶段已经识别出build()里的Text读取了count,赋值后框架会触发局部刷新。
使用@State有个重要习惯:不要在build()方法里面直接修改状态。build()是描述 UI 的地方,不是执行逻辑的地方。你一旦在里面写this.count++,每次刷新可能都会再触发一次修改,轻则死循环,重则页面卡死。需要修改状态时,放到按钮回调、事件监听或普通方法里。
2.3 父子组件通信:@Prop 和 @Link 怎么选
组件不可能永远不通信。父组件要把数据传给子组件,最常见的有两个装饰器:@Prop和@Link。
| 装饰器 | 数据流向 | 子组件能否修改 | 适用场景 |
|---|---|---|---|
| @Prop | 父 -> 子 | 能修改,但不会同步回父组件 | 纯展示型子组件 |
| @Link | 父 <-> 子 | 能修改,且同步回父组件 | 子组件需要操作父组件状态 |
用@Prop时,父组件传的是一份快照,子组件内部改了,父组件感知不到。很多新人在这里踩坑:子组件里给@Prop赋值,界面刷新了,但父组件一看原数据没变,以为 bug 了。其实不是 bug,是选错了装饰器。
如果子组件要修改数据且希望父组件知道,就用@Link。@Link不是拷贝关系,它和父组件的状态变量共享同一个数据源,子组件赋值,父组件随即同步。比较典型的场景是筛选栏:筛选条件定义在父组件,子组件点击按钮修改条件,父组件根据条件重新渲染列表。
还有一点要注意:@Link要求父组件传的是状态变量的引用,不能传字面量。写Child({ count: 1 })是错的,必须先有@State count = 1,然后传Child({ count: this.count })。
2.4 跨层级共享状态:@Provide / @Consume
有些状态在组件树里隔了很多层,如果每一层都用@Prop往下传,中间层就要转发一堆与自己无关的数据,非常啰嗦。这种时候用@Provide和@Consume更合适。
父组件用@Provide提供数据,任意后代组件用@Consume注入同一个数据。两者之间靠 key 匹配,也可以让类型自动匹配。
@Provide('themeColor') themeColor: string = '#007AFF'; // 后代组件 @Consume('themeColor') themeColor: string;这样中间层完全不用感知这个数据。适合放主题色、用户信息、默认筛选条件这类全局性较强的状态。但我不建议把所有状态都@Provide出去,全局状态一旦多了,排查问题时要顺着组件树找来源,反而更累。局部能解决的就局部解决。
2.5 观察“对象内部”:@Observed 与 @ObjectLink
@State观察的是“变量本身”的变化,比如重新赋值、数组整体变化。但如果你有一个对象,修改对象的某个属性,会不会刷新?
直接修改一个普通对象里的字段,大多数情况下是不会触发 UI 刷新的。这时候需要两个装饰器配合:在类上标@Observed,在子组件接收该对象时用@ObjectLink。
@Observed class TodoItem { title: string; done: boolean; constructor(title: string, done: boolean = false) { this.title = title; this.done = done; } }子组件里用@ObjectLink item: TodoItem接收后,直接修改item.done = true,界面就能感知到。这个组合特别适合列表项组件:列表数据由父组件持有,列表项的交互行为又要直接修改对象内部属性,用@ObjectLink既避免了把整个数组传来传去,又能拿到精确的刷新粒度。
2.6 生命周期钩子:状态初始化别放错位置
ArkTS 组件常用生命周期包括aboutToAppear、aboutToDisappear,以及页面级的onPageShow、onPageHide。简单说,aboutToAppear相当于页面要被展示前的准备阶段,适合拉取数据、初始化状态;aboutToDisappear适合清理定时器、解绑监听。
我见过不少人在声明属性时直接写:
@State list: Item[] = [];然后就在aboutToAppear里发请求往list塞数据,这个思路没问题。关键是不要在build()里面直接触发网络请求或修改状态。因为build()可能在状态变化后多次执行,你会在渲染过程中埋下不稳定的改动源。
3. 从零写一个待办列表,把声明式和状态管理串起来
3.1 项目结构与数据模型设计
先规划一个入门级待办应用:顶部输入框,中间待办列表,底部三个筛选按钮(全部 / 未完成 / 已完成),外加一个剩余数量统计。虽然功能简单,但足够覆盖@State、@Prop、@Link、@ObjectLink这几个核心知识点。
我习惯先定数据模型,再写页面。因为状态管理的关键在数据,数据结构定了,装饰器才能决定放在哪里。
@Observed class TodoItem { id: number; title: string; done: boolean; constructor(id: number, title: string, done: boolean = false) { this.id = id; this.title = title; this.done = done; } }这里id必须给一个稳定且唯一的标识,后面ForEach渲染列表时会用到。
3.2 父页面:状态定义和基础布局
主组件用@Entry标记为页面入口,内部维护四个状态:待办列表、当前输入内容、筛选条件、剩余数量。
@Entry @Component struct TodoPage { @State todos: TodoItem[] = []; @State inputValue: string = ''; @State filter: string = 'all'; @State remaining: number = 0; build() { Column({ space: 12 }) { Row({ space: 8 }) { TextInput({ placeholder: '输入待办事项', text: this.inputValue }) .onChange((value: string) => { this.inputValue = value; }) Button('新增') .onClick(() => this.addTodo()) } Text(`剩余 ${this.remaining} 项未完成`) ForEach(this.filteredTodos(), (item: TodoItem) => { TodoItemView({ item: item, onDelete: (todo: TodoItem) => this.deleteTodo(todo) }) }, (item: TodoItem) => item.id.toString()) FilterBar({ filter: this.filter }) } .padding(16) } }注意ForEach的第三参数是 key 生成器。我直接用item.id.toString(),而不是用数组 index。用 index 当 key,在删除中间项时,后面所有项的 index 都会变,列表复用时容易产生状态错乱。
filteredTodos()是一个方法,根据filter返回过滤后的数组。这个方法只做计算,不修改状态,可以安全地在build()中调用。
3.3 新增待办:用不可变方式更新数组
新增逻辑比较简单,但有一个习惯很关键:更新数组时尽量生成新数组,而不是修改原数组。
private addTodo(): void { const title = this.inputValue.trim(); if (title.length === 0) { return; } const newTodo = new TodoItem(Date.now(), title, false); this.todos = [...this.todos, newTodo]; this.inputValue = ''; this.refreshRemaining(); }为什么用[...this.todos, newTodo]而不是this.todos.push(newTodo)?直接push理论上也能被观察,但在复杂的列表结构和@ObjectLink混用场景下,重新赋值更稳定。它明确地让@State todos指向一个新数组,框架能可靠地识别到变化。
空字符串直接提前返回,避免创建一堆空任务。这个判断虽然基础,但很多人会忘。
3.4 子组件用 @ObjectLink 展示和修改待办
列表项组件TodoItemView接收一个@ObjectLink修饰的TodoItem,加上一个删除回调。
@Component struct TodoItemView { @ObjectLink item: TodoItem; onDelete: (item: TodoItem) => void = () => {}; build() { Row({ space: 12 }) { Checkbox({ select: this.item.done }) .onChange((checked: boolean) => { this.item.done = checked; }) Text(this.item.title) .decoration({ type: this.item.done ? TextDecorationType.LineThrough : TextDecorationType.None }) Blank() Button('删除') .onClick(() => { this.onDelete(this.item); }) } .padding(12) .width('100%') } }因为TodoItem是@Observed类,@ObjectLink能够观察到item.done的属性变化,所以Text的删除线会立刻更新。这里省去了手动通知父组件的功夫,代码看起来也很直观。
删除操作因为要修改父组件的todos数组,所以通过回调传到父组件处理。这符合“谁持有数据,谁负责修改数据”的原则。
3.5 删除和筛选:状态变更与统计
删除逻辑要生成一个新数组并过滤掉对应项:
private deleteTodo(todo: TodoItem): void { this.todos = this.todos.filter((item: TodoItem) => item.id !== todo.id); this.refreshRemaining(); } private refreshRemaining(): void { this.remaining = this.todos.filter((item: TodoItem) => !item.done).length; }FilterBar组件用@Link直接双向绑定筛选条件:
@Component struct FilterBar { @Link filter: string; build() { Row({ space: 8 }) { Button('全部') .onClick(() => { this.filter = 'all'; }) Button('未完成') .onClick(() => { this.filter = 'todo'; }) Button('已完成') .onClick(() => { this.filter = 'done'; }) } } }父组件使用FilterBar({ filter: this.filter }),此时@Link生效。点击子组件里的按钮,父组件的filter会同步变化,列表自动重新筛选。这个模式比“子组件发出事件、父组件手动赋值”简洁得多。
3.6 计算列表:筛选逻辑放在哪里
filteredTodos()是列表渲染的数据源:
private filteredTodos(): TodoItem[] { if (this.filter === 'todo') { return this.todos.filter((item: TodoItem) => !item.done); } if (this.filter === 'done') { return this.todos.filter((item: TodoItem) => item.done); } return this.todos; }不需要在这里加@State,因为它是一个派生值,是从todos和filter两个状态计算出来的。如果加上@State,反而要维护一份缓存,搞不好还要在每次修改todos时同步它,属于多余的复杂度。
如果未来列表很大、过滤很慢,可以把filteredTodos的结果缓存起来,再配合@Watch监听依赖状态更新缓存。入门阶段先不用过度设计,保持直接计算反而更容易理解。
4. 状态管理高频问题与排查经验
4.1 UI 死活不刷新,先查这四件事
状态不刷新是入门阶段遇到最多的现象,通常不是框架问题,而是观察粒度没覆盖到。检查顺序:
- 这个字段是不是真的加了装饰器?普通字段不会触发刷新。
- 是不是修改了对象内部属性,但类没有加
@Observed? - 是不是修改了数组里某一项的某个属性,整个数组没重新赋值?
- 是不是在
build()里用了状态,但修改的时候组件已经脱离组件树?
其中第三点最隐蔽。常见写法是:
this.todos[0].title = '新标题';如果todos只是@State,而TodoItem不是@Observed,这个变更大概率不会刷新页面。正确做法是重新生成对象并替换数组,或者使用@Observed+@ObjectLink。
4.2 @Prop 修改不同步父组件,这不是 bug
@Prop的本质决定了它是单向的。子组件内部对@Prop赋值,UI 上可以立刻看到变化,但一旦子组件重新收到父组件的传参,本地修改就会被覆盖,父组件的数据也不会变。
如果你真的希望子组件改完,父组件能同步,就换@Link。如果担心双向绑定会让数据流难追,也可以保持单向,子组件通过回调把“要改的值”传给父组件,由父组件统一修改。两种方案没有绝对对错,但“谁的数据谁负责改”在大型项目中更省心。
4.3 @Link 绑定时的几个硬性限制
@Link不是随便哪里都能用的。我整理过几个典型报错:
| 错误写法 | 说明 |
|---|---|
Child({ count: 1 }) | 不能直接传字面量 |
Child({ count: this.count + 1 }) | 不能传表达式 |
Child({ count: someNumber }) | 目标必须是父组件的状态变量,普通字段不行 |
要让@Link正常工作,父组件侧必须有一个可观察的状态变量作为数据源。一旦子组件里this.filter = 'done',修改会立即同步到父组件。如果你用变量名排版觉得怪异,可以打开 DevTools 状态面板看同步路径,通常能直接定位。
4.4 异步回调里的状态:页面可能已经不存在
状态管理不止 UI 刷新,还关系到异步任务的安全。网络请求是比较典型的高频场景:用户进入详情页,请求还没回来就返回上一页,等回调执行时,组件可能已经销毁。这时再去修改状态,轻则日志报警,重则内存泄漏。
我的习惯是在aboutToDisappear里做标记或取消订阅,比如维护一个isActive标志,异步回调先判断它:
aboutToDisappear(): void { this.isActive = false; } // 网络回调里 if (this.isActive) { this.data = result; }另外还有一种竞态问题:快速切换两个条件,后发出的请求先返回,旧请求后返回,导致界面显示的是过期结果。前端圈通常用请求序号或时间戳解决,只接受最新一次请求的结果。
4.5 装饰器用错位置,编译器会直接报错
ArkTS 对装饰器的使用位置限制很明确:装饰器只能作用于组件类内的成员变量,不能修饰局部变量、全局变量、函数参数。看到@State写在方法里的代码,基本都是编译不过的。
还有一个容易出错的是@Provide和@Consume的 key 匹配。如果提供方写的是字符串 key,消费方也必须要同一个 key,大小写也要完全一致。用类型自动匹配时,要确保两边的类型完全一样,否则运行时找不到对应数据源。
遇到编译报错先别慌,ArkTS 的静态检查已经能拦截大部分问题,按提示把装饰器挪到正确位置,大多数情况能解决。
4.6 状态方案速查表
最后一个实战总结,遇到具体场景可以直接参考:
| 场景 | 推荐方案 |
|---|---|
| 组件内部独立的 UI 状态 | @State |
| 父传子,子组件只展示 | @Prop |
| 子组件修改父组件状态并同步 | @Link |
| 跨多层组件共享全局数据 | @Provide / @Consume |
| 修改对象内部属性需要刷新 | @Observed + @ObjectLink |
| 页面级共享、应用级共享 | AppStorage + @StorageLink / @StorageProp |
| 需要监听状态变化并执行逻辑 | @Watch 配合状态装饰器 |
这个表格不是死规定,但它可以帮你快速起步。状态管理最重要的不是把所有装饰器都用上,而是想清楚“谁来改数据、改完谁要知道”。设计清楚了,声明式语法会自动帮你处理剩下的大量界面刷新工作。
我在实际开发里养成的一个习惯是:写组件之前先在纸上画一条数据流,几号组件负责展示,几号组件负责交互,改动最终由谁落地。然后再决定用哪个装饰器。很多“页面不刷新”“父子不同步”的问题,其实在动手前就能避免。ArkTS 的状态管理入门并不复杂,复杂的是状态一多,人容易忘掉数据是从哪里来、往哪里去。只要始终记得“UI 是状态的投影”,你就能在这个体系里站稳脚跟。