HarmonyOS @Local 完全指南:V2 组件私有状态的不可变更新模式
前言
@Local是 HarmonyOS 状态管理 V2 中替代@State的私有状态装饰器。与@State相比,@Local有两项关键改进:严格私有性(父组件无任何途径读写它)和更清晰的更新语义(明确要求不可变更新模式,让数据流方向一目了然)。本文用一个完整的 TodoList 演示@Local操作数组、对象和基本类型的全套模式,以及 ArkTS 严格模式下的常见编译陷阱。
核心 API
@Local 声明语法
@Entry @ComponentV2 struct MyPage { @Local count: number = 0 // 基本类型 @Local title: string = '待办清单' // 字符串 @Local todos: Todo[] = [] // 数组 @Local filter: string = 'all' // 枚举字符串 }触发 UI 更新的规则
| 数据类型 | 触发更新的写法 | 不触发更新的写法 |
|---|---|---|
| 基本类型 | this.count++✅ | — |
| 字符串 | this.title = '新标题'✅ | — |
| 数组 | this.todos = [...this.todos, item]✅ | this.todos.push(item)❌ |
| 对象 | this.profile = { ...fields }✅ | this.profile.name = '新'❌ |
核心规则:
@Local检测的是引用变化,不是内容变化。数组和对象必须生成新引用才能触发重渲染;基本类型赋值本身就是新值,天然触发。
@Local vs @State 关键差异
// V1:@State 可被父组件通过 @ObjectLink 访问 @State items: Todo[] = [] // 父组件可通过 @ObjectLink 读写 // V2:@Local 严格私有,外部无法读写 @Local items: Todo[] = [] // 只有本组件内部能修改实现思路
用一个 TodoList 覆盖@Local的三种数据类型:@Local todos: Todo[]展示数组的不可变增删改;@Local filter: string展示字符串赋值;@Local nextId: number展示基本类型自增。getStats()返回派生统计数据(后续 article68 中@Computed会自动缓存此类计算)。ArkTS 不允许对象展开{ ...obj },所有对象更新须逐字段构造,在第 3、4 步中重点展示。
逐步实现
第 1 步:定义接口和 @Local 状态
interface Todo { id: number text: string done: boolean } @Entry @ComponentV2 struct Index { @Local todos: Todo[] = [] // 任务列表 @Local nextId: number = 1 // 自增 ID(基本类型) @Local filter: string = 'all' // 过滤器(字符串) @Local inputText: string = '' // 输入框绑定三种类型一次覆盖:数组、基本类型、字符串,触发方式各不相同。
第 2 步:添加任务——数组不可变更新
private addTodo(): void { const text = this.inputText.trim() if (!text) return // [...旧数组, 新元素] 产生新数组引用,触发 @Local 更新 this.todos = [...this.todos, { id: this.nextId, text, done: false }] this.nextId++ // 基本类型直接赋值 this.inputText = '' // 清空输入框 }[...this.todos, newItem]是数组的标准不可变添加模式,等价于 V1 的this.todos = this.todos.concat([newItem])。
第 3 步:切换完成状态——map 返回新数组
private toggleDone(id: number): void { // ArkTS 禁止对象展开 { ...t },须逐字段构造新对象 this.todos = this.todos.map(t => t.id === id ? { id: t.id, text: t.text, done: !t.done } // 新对象 : t ) }map()天然返回新数组,配合逐字段构造对象,一行完成不可变更新。注意 ArkTS 严格模式禁止{ ...t, done: !t.done }(arkts-no-spread错误),必须显式列出每个字段。
第 4 步:删除任务——filter 返回新数组
private removeTodo(id: number): void { this.todos = this.todos.filter(t => t.id !== id) }filter()同样返回新数组,是最简洁的不可变删除写法。
第 5 步:过滤显示与统计
private filteredTodos(): Todo[] { if (this.filter === 'active') return this.todos.filter(t => !t.done) if (this.filter === 'done') return this.todos.filter(t => t.done) return this.todos } private getStats(): Stats { const done = this.todos.filter(t => t.done).length return { total: this.todos.length, done, active: this.todos.length - done } }切换this.filter字符串值即触发重渲染,filteredTodos()返回对应子集。
完整代码
// Article 62: @Local 私有状态深度解析 interface Todo { id: number text: string done: boolean } interface Stats { total: number done: number active: number } @Entry @ComponentV2 struct Index { @Local todos: Todo[] = [] @Local nextId: number = 1 @Local filter: string = 'all' @Local inputText: string = '' private addTodo(): void { const text = this.inputText.trim() if (!text) return this.todos = [...this.todos, { id: this.nextId, text, done: false }] this.nextId++ this.inputText = '' } private toggleDone(id: number): void { this.todos = this.todos.map(t => t.id === id ? { id: t.id, text: t.text, done: !t.done } : t ) } private removeTodo(id: number): void { this.todos = this.todos.filter(t => t.id !== id) } private getStats(): Stats { const done = this.todos.filter(t => t.done).length return { total: this.todos.length, done, active: this.todos.length - done } } private filteredTodos(): Todo[] { if (this.filter === 'active') return this.todos.filter(t => !t.done) if (this.filter === 'done') return this.todos.filter(t => t.done) return this.todos } private filterLabel(f: string): string { if (f === 'active') return '待完成' if (f === 'done') return '已完成' return '全部' } build() { Column({ space: 0 }) { Row() { Text('@Local 私有状态').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1a1a1a') } .width('100%').height(56).backgroundColor('#ffffff').padding({ left: 16 }) .border({ width: { bottom: 1 }, color: '#f0f0f0' }) Scroll() { Column({ space: 12 }) { Row({ space: 0 }) { Column({ space: 4 }) { Text(`${this.getStats().total}`).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#1a1a1a') Text('全部').fontSize(11).fontColor('#aaa') } .layoutWeight(1).alignItems(HorizontalAlign.Center) Column({ space: 4 }) { Text(`${this.getStats().active}`).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#0066ff') Text('待完成').fontSize(11).fontColor('#aaa') } .layoutWeight(1).alignItems(HorizontalAlign.Center) Column({ space: 4 }) { Text(`${this.getStats().done}`).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#07c160') Text('已完成').fontSize(11).fontColor('#aaa') } .layoutWeight(1).alignItems(HorizontalAlign.Center) } .width('100%').backgroundColor('#ffffff').borderRadius(12).padding(16) .margin({ left: 12, right: 12 }) Column({ space: 10 }) { Text('添加任务').fontSize(13).fontColor('#888').fontWeight(FontWeight.Medium).width('100%') Row({ space: 10 }) { TextInput({ placeholder: '输入任务内容…', text: this.inputText }) .layoutWeight(1).height(44).borderRadius(22).fontSize(14) .backgroundColor('#f5f5f5').padding({ left: 16, right: 16 }) .onChange((v: string) => { this.inputText = v }) Button('添加') .height(44).borderRadius(22).fontSize(14).padding({ left: 20, right: 20 }) .backgroundColor(this.inputText.trim() ? '#0066ff' : '#e0e0e0').fontColor('#fff') .onClick(() => this.addTodo()) } } .width('100%').backgroundColor('#ffffff').borderRadius(12).padding(16) .margin({ left: 12, right: 12 }) Row({ space: 8 }) { ForEach(['all', 'active', 'done'] as string[], (f: string) => { Text(this.filterLabel(f)) .fontSize(13).padding({ left: 16, right: 16, top: 6, bottom: 6 }).borderRadius(16) .fontColor(this.filter === f ? '#fff' : '#666') .backgroundColor(this.filter === f ? '#0066ff' : '#f0f0f0') .onClick(() => { this.filter = f }) }) } .width('100%').padding({ left: 16, right: 16 }) Column({ space: 8 }) { if (this.filteredTodos().length === 0) { Text('暂无任务').fontSize(14).fontColor('#ccc').margin({ top: 20 }) } else { ForEach(this.filteredTodos(), (todo: Todo) => { Row({ space: 12 }) { Text(todo.done ? '✅' : '⬜').fontSize(20) .onClick(() => this.toggleDone(todo.id)) Text(todo.text) .fontSize(14).layoutWeight(1).fontColor(todo.done ? '#bbb' : '#1a1a1a') .decoration({ type: todo.done ? TextDecorationType.LineThrough : TextDecorationType.None }) Text('✕').fontSize(16).fontColor('#ddd') .onClick(() => this.removeTodo(todo.id)) } .width('100%').backgroundColor('#ffffff').borderRadius(12) .padding({ left: 16, right: 16, top: 14, bottom: 14 }) }) } } .width('100%').padding({ left: 12, right: 12, bottom: 24 }) } .width('100%').padding({ top: 12 }) } .layoutWeight(1).backgroundColor('#f8f8f8') } .width('100%').height('100%').backgroundColor('#f8f8f8') } }运行效果
初始态:列表为空,三项统计均为 0
添加 4 条任务后:全部 4 / 待完成 2 / 已完成 2,前两条显示删除线
注意事项
ArkTS 禁止对象展开:
{ ...obj, key: val }会触发arkts-no-spread编译错误,必须逐字段构造新对象:{ id: t.id, text: t.text, done: !t.done }。这是 ArkTS 与标准 TypeScript 的重要区别,迁移代码时需全面替换。数组方法的可变/不可变区别:
push/pop/splice/sort(原地变更)→ 不触发@Local更新map/filter/concat/[...arr](返回新数组)→ 触发更新 始终使用返回新数组的方法。
build() 内不能声明变量:
ForEach回调或其他 UI 构建函数内不允许const/let声明(Only UI component syntax can be written here),需提取为组件方法(如filterLabel())。@Local 严格私有:父组件不能通过任何方式读取或修改子组件的
@Local状态。如果需要父写子,用@Param(article63);子写父,用@Event(article64)。派生计算的性能:
getStats()和filteredTodos()每次渲染都重新计算。当列表较大时(>1000 项),应改用@Computed缓存(article68),避免不必要的重复遍历。