上周组里新人跑过来问我:“老三,@Watch 有时候不触发是怎么回事?我明明赋了值,回调就是不进来。”
我随口回了一句:"你检查一下是不是赋值了同一个值——严格相等没变化,@Watch 当然不触发嘛。"他回去试了,加了 console.log 确认赋值确实发生了,值确实变了,但回调还是没进来。这就尴尬了。我凭多年经验给的答案,明显没命中根因。
那天下班之后我咬着牙把 ArkUI 状态管理那部分源码翻了一遍。说实话,翻完之后我不太敢跟新人解释了——因为我发现自己之前三年的用法也有问题。@Watch 的触发机制跟绝大部分人以为的完全不一样,官方文档虽然提了严格相等,但对脏标记和渲染帧的关系几乎一笔带过。下面把我翻源码发现的三个反常识行为逐个展开。
不是"赋值即触发",是"脏标记 + 渲染帧"
大部分人的直觉很合理:给 @State 变量赋了新值,@Watch 立刻触发回调。毕竟 Vue 的 watch 和 React 的 useEffect 都是赋值就响应,ArkUI 不也应该这样吗?
源码告诉你不是。ArkUI 内部给每个被 @Watch 装饰的状态变量维护了一个脏标记(DirtyFlag)。赋值操作发生时,框架只做一件事:把脏标记打上。回调函数的真正触发,发生在下一次渲染帧(render tick)里统一执行。换句话说,@Watch 不是"赋值即触发",而是"攒一波再触发"。这跟 Vue 的 immediate: true watch、React 的 useEffect layout 阶段完全不是一个路子。
你试试在同一个函数里对同一个变量连续赋值两次:
@State count: number = 0;
@Watch('onCountChanged')
onCountChanged(oldValue: number, newValue: number) {
console.log(`count: ${oldValue} → ${newValue}`);
}
// 同一帧内赋值两次
onClick() {
this.count = 1; // 脏标记打上,值更新为 1
this.count = 2; // 脏标记已存在,只更新值为 2,不走重复检测
}
你以为会打印两条日志,分别看到 0 → 1 和 1 → 2?实际只有一条:count: 0 → 2。中间那个 1 从来没进过回调——脏标记在第一次赋值时已经打了,第二次赋值只是把最终值从 1 更新到 2,标记不会重复触发。
等一下,这里我漏说一个前提——如果你用 setTimeout 把两次赋值拆到不同的渲染帧,@Watch 会分别触发两次。核心区别在于"同一帧"内赋值才会被合并。所以如果你需要在每次赋值后都立刻触发回调,就得确保每次赋值之间至少有一个空帧间隔。这个前提在官方文档里压根没提,我也不知道该从哪个版本开始指望他们写上去。
oldValue 的"谎言"
这是翻源码最让我意外的部分。@Watch 回调签名里给了 oldValue 和 newValue 两个参数,看起来贴心——但 @Link 传递场景下,这两个值可能一模一样。
@Link 是引用传递,不做值拷贝。当父组件的 @State 对象某个内部属性变化时,子组件的 @Link 拿到的 oldValue 是"上次脏检测时那个对象的引用"。如果变化的是对象里某个属性而不是整个对象被替换,引用本身没变——严格相等一比,oldValue === newValue 直接返回 true。
// 父组件
@State user: UserInfo = { name: '老三', age: 35 };
// 子组件
@Link user: UserInfo;
@Watch('onUserChanged')
onUserChanged(oldValue: UserInfo, newValue: UserInfo) {
// 你以为 oldValue.name 是 '老三',newValue.name 是 '老三哥'
// 实际:oldValue 和 newValue 是同一个对象引用
console.log(oldValue === newValue); // true !!!
console.log(oldValue.name); // '老三哥'(已经被改了)
console.log(newValue.name); // '老三哥'
}
我承认看到 oldValue === newValue 输出 true 的时候愣了好几秒,心想"这特么有什么用?“。oldValue 和 newValue 指向同一个对象,你从 oldValue 里读到的值已经被改过了,根本拿不到所谓的"旧值”。
说白了,@Watch 对嵌套对象属性变化的 oldValue 参数就是个摆设。你要拿真正的旧值,只能自己在赋值前手动深拷贝一份存下来:
// 实际解决方案:手动保存旧值
@State user: UserInfo = { name: '老三', age: 35 };
private prevUser: UserInfo = { …this.user };
@Watch('onUserChanged')
onUserChanged() {
const realOld = this.prevUser;
const realNew = { …this.user };
console.log(`${realOld.name} → ${realNew.name}`); // '老三 → 老三哥'
this.prevUser = { …this.user }; // 更新保存值,为下次做准备
}
丑吗?丑。但在 @Link 场景下目前没有更优雅的办法,源码里就是严格相等比对引用而不是比对内容。我当时把整个 UserInfo 的监听逻辑改成手动存值,多了十几行胶水代码,但至少 oldValue 不再是摆设了。
@Prop 更离谱——嵌套属性变化完全不触发
@Prop 做的是浅拷贝。父组件传一个对象给子组件的 @Prop,子组件拿到的是一份新的顶层引用,但嵌套属性还是指向父组件原来的地址。
这就导致了一个更隐蔽的问题:在父组件修改对象内部属性时(不是替换整个对象),@Prop 的严格相等检测看到的是"引用没变"——因为浅拷贝之后子组件里嵌套属性的引用还指向父组件同一个对象,那个对象的属性值已经被改了,但子组件的 @Prop 顶层引用没变。脏标记不会打,@Watch 完全不触发。
// 父组件
@State config: AppConfig = { theme: { color: '#333', fontSize: 14 } };
// 子组件
@Prop config: AppConfig;
@Watch('onConfigChanged')
onConfigChanged() {
console.log('config changed'); // 永远不会打印!
}
// 父组件修改嵌套属性——@Watch 不触发
onClick() {
this.config.theme.color = '#666';
}
// 替换整个对象才能触发
onClick2() {
this.config = { …this.config, theme: { …this.config.theme, color: '#666' } };
// 这一次 @Watch 会触发,因为顶层引用变了
}
这个坑我在两个页面上踩过,排查了半小时才搞明白不是 @Watch 的 bug,而是 @Prop 浅拷贝的机制决定了它只监测顶层引用变化。你要监听嵌套属性的变化,要么每次赋值时做深拷贝替换整个对象,要么把嵌套属性单独拆出来变成独立的 @State:
// 解决方案:拆成独立状态变量
@State themeColor: string = '#333';
@State themeFontSize: number = 14;
@Watch('onThemeColorChanged')
onThemeColorChanged(oldValue: string, newValue: string) {
// 字符类型的 oldValue 和 newValue 正常工作
console.log(`${oldValue} → ${newValue}`); // '#333 → #666'
}
我个人特别讨厌这种设计——明明叫"状态管理",却只管顶层引用不管内容变化。但想了想,ArkUI 大概是为了渲染性能做的取舍,每次深比对嵌套对象的开销确实不可接受。算是个合理的妥协,只是文档上应该把这点写得明明白白,别让人踩了坑再去翻源码才发现真相。
翻完源码那天晚上,我把三个页面的 @Watch 逻辑全重写了。一个用了手动存旧值方案,两个改成了拆分独立状态变量。代码跑起来之后,该触发的都触发了,不该触发的也不再乱触发。
顺带一提,我做的 App 叫雷达鸭,鸿蒙版就是用 ArkTS 写的,里面列表页的状态监听刚好踩了 @Link oldValue 等于 newValue 的坑,改成手动存值之后才正常。
等鸿蒙 Next 出来,希望官方能在 @Watch 上做两个改动:一是给嵌套对象场景提供真正的深比对 oldValue(哪怕是个可选参数),二是让 @Prop 至少支持可选的深拷贝模式。不然每次都得手动补,说实话有点蠢。
关于作者:老三,10+ 年软件开发老兵,软件设计师 & 人工智能应用工程师,专注鸿蒙 ArkTS 北向开发 + Web 前端,偶尔用 AI 做自动化。不定期在 CSDN 分享鸿蒙和 AI 方向的技术文章,多数是踩坑复盘,少数是翻源码后的心虚记录。
本文遵循 MIT 协议,转载请注明出处。

