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),避免不必要的重复遍历。
更多推荐



所有评论(0)