
调用链中状态管理V2的定位想聊 Local 之前得先把它的位置摆正。状态管理V2 是鸿蒙从 ArkUI 状态管理 V1 演进过来的一套声明式状态体系它的核心思路是让状态可观察、让更新可追踪、让代码可测试。而 Local 是 V2 体系里最基础的一批装饰器之一——它代表组件自身持有的、局部的、可观察的普通状态。通俗点讲Local 就是给组件内部变量贴上一个可观察的标签让变量变化时UI 能自动跟着刷新。很多刚接触 V2 的开发者会问既然 V1 里已经有 State为什么还要搞一个 Local答案在于 V2 对状态来源做了更细致的划分。在一个复杂的业务页面里状态可以来自组件自己本地、可以来自父组件注入、可以来自全局应用级共享。V1 时代这些来源经常混在一起一个 State 既当本地变量又当属性透传业务复杂后很难分清谁改了什么。V2 用 Local、Param、Global 三个装饰器把来源切分清楚其中 Local 只负责我自己这一亩三分地。这篇笔记适合正在学鸿蒙状态管理、想从 V1 平滑过渡到 V2、或者已经上手 V2 但对 Local 细节还不熟的开发者。我会把 Local 的定义、用法、适用场景、底层原理、常见坑一次性讲透代码基于 API 12 及以上实测可用。1. 内容整体设计与思路拆解1.1 V1到V2状态管理为什么需要一次换代先回顾一下 V1 里最常见的问题。V1 的 State 设计得很早当时 ArkUI 组件化还没那么成熟所以它承担了太多职责既能标记本地状态又能通过赋值触发子组件更新还能借助 $ 双向绑定做一些隐式操作。这些能力叠加在一起的后果是状态流的可预测性变差。我在实际项目里遇到过一个很典型的情况一个深层的子组件不小心直接改了父组件传下来的 State 数组里的某个对象属性结果整条链路的状态都乱了排查了整整一个下午最后发现是引用共享导致的隐式修改。V2 的诞生就是冲着这些问题来的。它把状态分成了三类来源用三个装饰器表达装饰器状态来源典型场景Local组件自身持有页面内部的临时开关、输入框文本、下拉选中值Param父组件传入父组件配置项、子组件初始化参数Global应用级共享登录态、主题配置、跨页面数据这个划分的好处一眼就能看出来每个状态都有明确的所有权。谁拥有、谁修改、谁负责变成了可执行的代码规范。你在 code review 时只要看到 Local 就知道这变量只属于当前组件绝不会漂移到别处看到 Param 就知道它只读想改就得通过回调。1.2 Local 在 V2 体系中的生态位Local 对应英文 Local State直译就是局部状态。它装饰的变量由组件自己管理和初始化不依赖外部传入。V2 框架会为这些变量建立观察能力变量值发生变化时依赖它的 UI 部分会自动重新渲染。用生活化类比来理解Local 就像你办公桌上的一本便利贴写什么、改什么都是你自己的事不需要老板审批也不需要同事签字。而 Param 是公司下发的任务文档内容是别人拟好的你只能照着执行Global 是公司公告栏所有人都能看到改一条全员知晓。从框架实现角度看Local 底层通过属性代理和依赖收集机制实现观察。简单说当你访问一个 Local 变量的值时框架会记录当前 UI 依赖于这个变量当你给变量赋新值时框架会通知所有依赖方重新求值。这个依赖记录-变更通知的模型和 Vue 的响应式原理非常像只是鸿蒙做了自己的 ArkUI 运行时实现。1.3 为什么状态来源划分这么重要很多初学者觉得多一个装饰器就是多一个语法糖其实不然。状态来源划分带来三个直接收益第一个收益是代码可读性提升。读代码时不再需要追着变量满文件跑Local 一眼就能确认变量的作用域和生命周期。第二个收益是变更范围可控。状态归谁管谁就能改从机制上杜绝了跨层隐式修改。第三个收益是测试性增强。V2 的状态类可以被独立实例化出来做单测这在 V1 时代几乎不可能。我自己的体会是V2 这套设计更像是在给前端的状态所有权立规矩。规矩立好了协作开发和后期维护都会轻松很多。特别是团队超过三个人同时做一个页面时这个优势会被放大得非常明显。以前 V1 时代 code review 需要反复确认这个 State 是不是被哪个子组件联动修改了现在看到 Local 就心里有数了。2. 核心细节解析与实操要点2.1 Local 的基础用法五步上手先看一个最基础的例子一个带计数器的小组件Entry Component struct CounterPage { Local count: number 0; build() { Column({ space: 12 }) { Text(当前计数${this.count}) .fontSize(24) Button(加一) .onClick(() { this.count; }) } .padding(16) } }这个例子麻雀虽小五脏俱全。关键的五个步骤是导入组件依赖API 12 起 V2 装饰器无需显式 importDevEco Studio 会自动处理。用 Local 修饰变量并赋初始值。在 build 里通过 this.xxx 读取变量。在事件回调里通过 this.xxx 新值 修改变量。界面自动刷新无需手动调用任何更新方法。你可能会问为什么 V2 的 Local 可以不用描述类型因为在 V2 体系中Local 支持类型推断基础类型、对象、数组、联合类型都能直接推断。当然显式标注类型依然是更严谨的写法尤其在大型项目里显式类型本身就是一种文档。实操心得我建议所有 Local 变量都显式初始化。V2 在未初始化时虽然不会报错但可能出现不确定的初始渲染尤其是对象类型。显式初始化还能顺便当作默认值文档后面维护的人一眼能看出这个组件的初始样子。遇到过不少线上问题最后查下来就是某个变量忘了初始化导致首帧渲染拿到的值是 undefined。提示Local 修饰的变量必须是组件实例的成员变量不能是局部变量。局部变量用不上装饰器也不具备观察能力。2.2 对象与数组Local 的深度观察能力V2 的 Local 不只是观察基础类型它对对象和数组也做深层次观察。看这个例子Entry Component struct UserCard { Local user: { name: string; age: number } { name: 张三, age: 25 }; Local tags: string[] [开发者, 鸿蒙]; build() { Column({ space: 12 }) { Text(姓名${this.user.name}) Text(年龄${this.user.age}) ForEach(this.tags, (tag: string) { Text(tag) }) Button(改名).onClick(() { this.user.name 李四; // 深层对象的属性修改也能触发UI更新 }) Button(加标签).onClick(() { this.tags.push(开源爱好者); // 数组方法调用同样触发UI更新 }) } .padding(16) } }这段代码里this.user.name 李四 直接修改了嵌套属性的值this.tags.push() 调用了数组的变异方法两处都会触发界面刷新。这在 V1 的某些场景下是需要额外处理才能做到的V2 把它们变成了默认行为。底层原理Local 在初始化时会对对象类型进行代理包装对数组则拦截变异方法push、pop、splice 等和索引赋值。访问属性时收集依赖修改属性或调用变异方法时触发更新。实操注意事项对象的整体替换this.user { name: 王五, age: 30 }同样生效且性能更优因为一次变更通知就能完成整个子树更新。数组的索引直接赋值 this.tags[0] x 在 V2 中也可以被观察到但建议优先用 splice 或整体替换语义更清晰。索引赋值的可读性比较差后面的人看代码时不知道你为什么只改了一个位置。不要使用解构赋值去接住对象的某一层然后修改解构出来的临时对象那样修改的是副本UI 不会更新。这个坑我在后面排查案例里会详细说。2.3 Local 与 State 的关键差异对比维度StateV1LocalV2状态来源混合语义既本地又透传仅本地持有对象深观察部分场景依赖额外处理默认深度代理数组方法个别版本需 Observed 配合默认拦截变异方法类实例需配合 Observed 和 ObjectLink可直接观察普通类跨层传递可双向绑定存在隐式修改风险组件私有杜绝外泄选择建议新项目一律用 V2老项目迁移时把纯本地状态的 State 直接替换成 Local风险很低把父传子的 Prop/Link 替换成 Param 回调这个迁移工作量稍大但值得做。我自己在迁移一个中型项目时光是替换 Prop/Link 就花了两天但迁移完之后的双周迭代明显顺畅了。3. 实操过程与核心环节实现3.1 实操前的环境准备与版本确认动手之前先把环境对齐我实测的配置是DevEco Studio 5.0.0 及以上版本API 12HarmonyOS NEXT 及以上或更高项目编译目标设置为 API 12并且启用了 V2 状态管理新版 DevEco Studio 默认支持检查是否处于 V2 模式最直接的方法是看工程里有没有开启状态管理 V2 的编译开关。新版工程模板里build-profile.json5 或 module.json5 中会有相关配置项如果找不到开关那么默认就是 V2 模式。有几个环境细节要注意。第一模拟器和真机的行为在某些版本上会有细微差异数组的变异方法在模拟器上如果出现不刷新的情况优先在真机上复现确认不要急着怀疑是代码问题。第二DevEco Studio 的实时预览对 V2 装饰器的支持在个别版本有延迟代码改动后如果预览不刷新重启一下预览进程就好不代表代码本身有错。第三如果项目是从旧版本升级上来的注意清理构建缓存我在升级后遇到过装饰器没生效的假象清理重编后就正常了。3.2 从 V1 迁移的最小改造实例假设有一个老项目里这样写Entry Component struct OldPage { State count: number 0; State list: string[] []; build() { // ...省略UI } }迁移到 V2只需要做两处改动Entry Component struct NewPage { Local count: number 0; Local list: string[] []; build() { // ...UI不变 } }注意这个最小改造只适用于组件自身持有、不依赖父组件的状态。如果 State 状态来自父组件透传那就要一并对父子的通信方式做改造不能简单替换。我的建议是迁移时按由叶到根的顺序推进。先迁移不依赖任何外部状态的叶子组件再逐步迁移中层最后处理根组件状态。每迁移一层跑一遍构建和关键路径冒烟测试不要一次性大范围替换否则出问题不好定位。我曾经见过一个团队试图在一个大版本迭代里同时迁移十几个页面结果线上出了三个关联性 bug花了双倍时间才排完。3.3 构建一个带搜索过滤的列表页完整示例来一个更接近真实业务的完整示例一个带搜索过滤的商品列表页Entry Component struct ProductListPage { Local keyword: string ; Local products: Product[] []; Local filtered: Product[] []; aboutToAppear(): void { // 模拟从服务端拉取数据 this.products [ { id: 1, name: 鸿蒙开发板, price: 299 }, { id: 2, name: 智能门锁, price: 599 }, { id: 3, name: 车载中控屏, price: 1299 }, ]; this.filtered [...this.products]; } build() { Column({ space: 12 }) { TextInput({ placeholder: 输入商品名过滤 }) .onChange((value: string) { this.keyword value; this.filtered this.products.filter( item item.name.includes(this.keyword) ); }) List({ space: 8 }) { ForEach(this.filtered, (item: Product) { ListItem() { Row({ space: 8 }) { Text(item.name) Text(¥${item.price}) } } }, (item: Product) item.id.toString()) } .layoutWeight(1) } .padding(16) } } interface Product { id: number; name: string; price: number; }这里有两个容易忽略的细节。第一个细节是 filtered 数组必须整体替换不能直接修改原数组再期望自动过滤。因为过滤逻辑本身是生成新数组的过程不是原地修改。整体替换 this.filtered newArray 这一行V2 会认为依赖它的整个列表都变了从而触发完整重渲染。这里性能上可能有人担心全量重渲染会比精准更新慢但在列表规模不大百级以内的场景下整体替换的简单性和可靠性远大于那点性能差异。第二个细节是 ForEach 的 key 生成器。我用了 item.id.toString() 作为唯一键这样当数据更新时框架能精准对比出哪些列表项需要重建、哪些可以复用。如果不用 key 或者用不稳定的 key比如直接用 index列表复用时会产生渲染错乱。这个错乱很隐蔽表现是滚动到某个位置时突然出现重复项或者删除项后 UI 错位排查起来非常痛苦。3.4 在 Local 中使用自定义类V2 可以直接观察自定义类的实例属性这一点比 V1 舒服太多。看下面的示例Entry Component struct TaskCard { Local task: Task new Task(写周报, false); build() { Column({ space: 12 }) { Text(this.task.title) Checkbox() .select(this.task.done) .onChange((checked: boolean) { this.task.done checked; }) } .padding(16) } } class Task { title: string; done: boolean; constructor(title: string, done: boolean) { this.title title; this.done done; } }在 V1 时代想要观察一个自定义类实例的属性变化你得给类加 Observed 装饰器还要在父组件用 ObjectLink 接住。V2 直接取消了这套繁琐的配套Local 对任意类实例的属性都有观察能力。实测下来类实例字段的修改、嵌套对象属性的修改、数组内对象的属性修改都能稳定触发更新。一个小提醒虽然 Local 能观察普通类实例但在设计数据模型时我还是建议把可观察数据和业务逻辑方法分开。不要在数据类里塞一堆方法保持类的纯粹性这样观察性能更好测试也更容易。比如上面这个 Task 类如果你给它加一个 saveToServer() 方法测试的时候就要 mock 网络层很麻烦不如把保存逻辑抽到 Page 层或者独立的 Service 里。4. 状态更新机制与框架源码视角4.1 依赖收集与更新触发链路把 V2 底层的更新链路拆开看主要有四个环节读取阶段UI 渲染时访问 Local 变量框架记录哪个 UI 节点依赖了这个属性。修改阶段代码给 Local 变量或它的深层属性赋值框架捕获这次写入。通知阶段框架根据依赖记录找到所有受影响的 UI 节点。渲染阶段框架只重新执行受影响节点的构建函数生成新的 UI。一个容易误解的点是不是整个页面都会重新渲染而是只有依赖了被修改属性的 UI 部分会重新渲染。ArkUI 的运行时会做精确到属性的依赖追踪这一点在复杂页面上的性能收益很明显。比如一个页面顶部有个用户头像底部有个数据列表你只修改了列表数据头像部分根本不会重新执行构建函数。4.2 框架如何包装对象类型对于对象类型的 Local 变量运行时在初始化时会生成一个代理对象。这个代理对象拦截属性访问和属性写入访问属性时检查当前是否有正在渲染的 UI 上下文如果有就建立依赖。写入属性时标记属性为 dirty并触发异步刷新。这里的设计目的很明确把数据操作和UI 更新解耦。开发者只管改数据框架负责在合适的时机批量刷新 UI避免每次赋值都同步重绘造成性能浪费。实际写代码时你不用关心这些但理解了这个机制遇到为什么我改了数据但是没立刻看到 UI 变化这类问题时就不会慌因为它可能只是框架在等下一个刷新周期。4.3 与 Vue 响应式原理的对照理解 V2 的响应式原理借 Vue 来对照会很快。Vue 3 的 reactive 用 Proxy 代理对象收集 effect 依赖触发 update。ArkUI V2 也是类似的思路代理对象做依赖收集赋值时触发通知。不同点是 ArkUI 的依赖不再是 effect 函数而是 UI 组件节点和它们的构建函数。这个对照不是为了掉书袋而是帮你迁移已有的前端知识。如果你懂 Vue 3 的响应式原理学习 V2 状态管理会快很多反过来如果你先学了 V2再去看 Vue 3 源码很多概念也能对上号。我在团队内部做技术分享时就常用这个类比前端同事理解起来特别快原生开发同事则需要多花一点时间但只要把构建函数当成自动重新执行的渲染函数来想也很快能通。5. 常见问题与排查技巧实录5.1 常见问题速查表问题现象可能原因解决办法修改对象属性后 UI 不刷新对象不是 Local 修饰或在解构副本上修改用 Local 声明直接在原对象上改属性数组 push 后界面不更新使用了不支持观察的旧版 API 或拿局部变量 push确认在 this 上调用必要时整体替换数组列表项复用后状态错乱ForEach 没用唯一 key 或 key 不稳定用业务 id 做 key避免用 index自定义类属性不更新类实例不是通过 Local 直接持有把实例字段挂到 Local 变量上页面销毁后还有异步任务改状态定时器/网络回调未清理在 aboutToDisappear 里清理异步任务5.2 一个典型的排查案例为什么改名没反应有位朋友遇到一个问题页面上有个 Text 显示用户名点击按钮修改 this.user.name但界面纹丝不动。代码长这样Entry Component struct BugPage { Local user: any { name: 张三 }; build() { Column() { Text(this.user.name) Button(改名).onClick(() { const tmp { ...this.user }; tmp.name 李四; this.user tmp; }) } } }初看好像没问题整体替换对象嘛应该触发更新的。但问题恰恰出在解构赋值上{ ...this.user } 是浅拷贝如果对象只有一层确实能复制到 name 属性但如果 user 对象里有嵌套对象浅拷贝会把嵌套对象的引用一起拷过来导致对嵌套对象的修改仍然作用在原对象上。另一个可能的坑是tmp 是一个全新的普通对象V2 的代理没有覆盖它所以 this.user tmp 虽然触发了整体替换但 tmp 内部不再是代理对象后续对 tmp 的深层修改就观察不到了。我的建议是不要手动复制再赋值。直接 this.user.name 李四让框架的代理机制去处理。如果业务上确实需要不可变更新就在替换前对原对象做结构化克隆保证替换后的整个对象是全新的原始对象再由 Local 重新完成代理包装。5.3 状态管理的几个实战禁忌第一个禁忌是跨组件偷改状态。V2 虽然把状态来源分清了但如果你在子组件里仍然通过某种方式拿到了父组件的 Local 对象引用并修改它那就能绕过机制。所以 code review 时要重点检查有没有把 Local 对象作为参数传给子组件然后子组件直接改属性的写法如果有一定要改成回调模式。第二个禁忌是在 build 中做副作用。build 函数应该是纯渲染函数不能在里面发起网络请求、修改状态、生成随机数。V2 的依赖追踪在 build 执行时建立如果在 build 里改状态会重新触发 build造成死循环。我在实际项目里见过因为这个卡死的页面表现是点击一次之后整个页面假死日志里全是重复构建的警告。排查方法很简单把 build 里所有赋值语句和网络调用全部移到事件回调里就正常了。第三个禁忌是滥用 Local 存所有数据。Local 适合组件内部临时状态。如果是多组件共享数据优先考虑 Param 注入和回调如果是全局数据用 Global 或应用级存储。把全局登录态塞到某个页面的 Local 里一旦页面销毁状态就没了而且其他页面还访问不到。规范的做法是先判断状态的生命周期和作用域再决定用哪个装饰器而不是哪个顺手用哪个。5.4 与其他装饰器搭配的常见模式Local 很少单打独斗实践中常用的搭配有Local Param组件内部有自己状态又需要接受父组件的初始配置。Local Global本地临时状态 全局共享数据。Local Monitor监听本地状态变化执行副作用逻辑。Monitor 是 V2 里的监听装饰器用于观察 Local/Param/Global 等状态的变化然后执行自定义逻辑。比如搜索框场景里可以用 Monitor 监听 keyword 变化然后调用接口做防抖搜索而不是在 onChange 里写一堆逻辑。这个组合在 V2 项目里非常实用因为它把数据变更和响应动作之间的关系显式声明出来了后续加逻辑不用去翻 UI 回调。举个例子Monitor 监听 keyword 变化后自动过滤列表Entry Component struct MonitorSearchPage { Local keyword: string ; Local allItems: string[] [鸿蒙开发, 状态管理, 装饰器]; Local filteredItems: string[] []; Monitor(keyword) onKeywordChange() { this.filteredItems this.allItems.filter( item item.includes(this.keyword) ); } aboutToAppear(): void { this.filteredItems [...this.allItems]; } build() { Column({ space: 12 }) { TextInput({ placeholder: 输入关键字 }) .onChange((value: string) { this.keyword value; }) ForEach(this.filteredItems, (item: string) { Text(item) }) } .padding(16) } }这个写法把过滤逻辑从 onChange 里解放出来了。TextInput 负责改 keywordMonitor 负责响应变化并更新列表。代码读起来非常清爽后面如果要加埋点、加统计直接在 onKeywordChange 里追加就行不会污染 UI 层。6. 实操总结与进阶路线参考6.1 把 Local 用到熟练的标志什么时候算真正掌握了 Local我的判断标准是三条第一清楚它的状态来源定位拿到需求能立刻说出这个状态应该放 Local 还是 Param。第二知道深观察的边界知道哪些修改能触发更新、哪些不能。第三能写出可维护的状态代码不把业务逻辑混进 build不用 Local 过度承载跨组件数据。做到这三条你在团队里基本就是状态管理的靠谱担当了。别人遇到界面不刷新的怪问题大概率第一时间会来找你。6.2 接下来建议学习的方向掌握了 Local可以从两个方向继续深入一个是同体系内的扩展学习 Param 的传参和初始化、Global 的全局共享、Monitor 的监听编排、Computed 的计算属性。这些组合起来基本就是 V2 状态管理的全部骨架。特别是 Computed它解决的是派生状态的问题比如列表的统计数字、过滤后的数量这些都不该用普通变量手动维护而应该用计算属性自动推导。另一个是结合场景的实战用 V2 状态管理重构一个已有的 V1 页面从简单列表到复杂表单再到带刷新的详情页。每重构一个页面对装饰器边界的理解就深一层。我自己就是在重构一个含表单校验、多级联动、草稿保存的复杂页面时才真正理解了为什么 V2 要把状态来源分得这么清。6.3 最后分享一个我在实际使用中的小技巧写 Local 时我习惯把初始值写成具名常量而不是直接写在声明里。比如const DEFAULT_KEYWORD ; Entry Component struct SearchPage { Local keyword: string DEFAULT_KEYWORD; }这样做的原因是当后续要做重置功能时直接引用常量不用再复制一遍初始值也不会因为多次手写值不一致而出错。在状态多的页面里这个习惯能省下不少心智负担。比如一个筛选页有七八个筛选条件初始化时要赋值一遍重置时要再赋值一遍如果初始值是散落在各处的字面量重置功能很容易出现原样是 A重置后是 B的诡异问题。另外再补充一个小的实战经验如果你发现一个页面的 Local 变量超过 10 个就要警惕是不是状态拆分的粒度太粗了。可以考虑把一组强相关的状态封装成一个类用单个 Local 持有既减少装饰器数量又能天然形成一组“内聚状态”。比如筛选条件可以封装成 FilterState 类包含 keyword、category、priceRange 等字段整个组件只需要一个 Local filter 变量。这个习惯能显著提升代码的可读性。状态管理是一条可以走很深的路但掌握 Local 就是一个非常扎实的起点——它帮你理解 V2 的观察模型、依赖收集和更新链路。把这个基础打牢后后面的 Param、Global、Monitor 学起来都会轻松不少。希望这篇笔记能帮你在鸿蒙状态管理 V2 的路上少踩几个坑。