HarmonyOS @Local 完全指南:V2 组件私有状态的不可变更新模式 前言Local是 HarmonyOS 状态管理 V2 中替代State的私有状态装饰器。与State相比Local有两项关键改进严格私有性父组件无任何途径读写它和更清晰的更新语义明确要求不可变更新模式让数据流方向一目了然。本文用一个完整的 TodoList 演示Local操作数组、对象和基本类型的全套模式以及 ArkTS 严格模式下的常见编译陷阱。核心 APILocal 声明语法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 关键差异// V1State 可被父组件通过 ObjectLink 访问 State items: Todo[] [] // 父组件可通过 ObjectLink 读写 // V2Local 严格私有外部无法读写 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状态。如果需要父写子用Paramarticle63子写父用Eventarticle64。派生计算的性能getStats()和filteredTodos()每次渲染都重新计算。当列表较大时1000 项应改用Computed缓存article68避免不必要的重复遍历。