一、前言
看完 @Monitor 的源码后,我顺手也把 @Watch 的实现翻了一遍。看完发现,官方推荐从 @Watch 迁移到 @Monitor 是有道理的,不只是功能更强,实现上也更合理。
其实一开始只是想对比一下 @Monitor 和 @Watch 的实现差异,看看它们到底有什么不同。结果看完 @Watch 的代码,发现它的实现比我想象的要复杂,而且有一些设计上的权衡。
如果您有任何疑问、对文章写的不满意、发现错误或者有更好的方法,如果你想支持下一期请务必点赞~,欢迎在评论、私信或邮件中提出,非常感谢您的支持。🙏
二、@Watch 在解决什么问题
@Watch 的作用很简单:当状态变量变化时,触发你定义的回调函数。比如:
typescript
@State @Watch('onCountChange') count: number = 0;
onCountChange(): void {
console.log('count changed to', this.count);
}
这个需求本身不复杂,但实现起来要考虑几个问题:
- 如何建立状态变量和回调函数的关联
- 如何避免内存泄漏(组件销毁后,watch 函数应该被清理)
- 如何高效地触发回调
三、@Watch 的实现机制
看源码发现,@Watch 的实现用了这么一套机制:
- WatchFunc 包装回调函数 :每个 @Watch 装饰的函数会被包装成
WatchFunc实例,分配一个唯一的 id - WeakRef + FinalizationRegistry:为了避免内存泄漏,用 WeakRef 存储 WatchFunc 引用,用 FinalizationRegistry 在对象被 GC 时清理
- 通过 id 查找:状态对象只保存 watchId(一个 int32),执行时通过静态 Map 查找对应的 WatchFunc 实例
这个设计看起来有点绕,但确实解决了循环引用的问题。不过也带来了一些副作用。
1、完整的调用流程
先看初始化阶段。当你写 @State @Watch('onCountChange') count: number = 0 时,框架会:
- 创建一个
WatchFunc实例,把你的onCountChange函数包装进去 - 给这个
WatchFunc分配一个唯一的 id(静态计数器自增) - 把这个
WatchFunc用WeakRef包装后,存入一个静态的Map<WatchIdType, WeakRef<WatchFunc>> - 注册
FinalizationRegistry,等WatchFunc被 GC 时,自动从 Map 中删除对应的条目 - 调用
watchFunc.registerMeTo(observedObject),把 watchId 注册到状态对象上 - 状态对象内部有个
SubscribedWatches,它维护一个Set<WatchIdType>,把 watchId 加进去
这里有个细节:状态对象只保存 watchId(一个 int32),不直接保存 WatchFunc 的引用。这是为了避免循环引用。
再看触发阶段。当状态变量被修改时(比如 this.count = 10):
- 状态对象调用
executeOnSubscribingWatches('count') SubscribedWatches遍历它维护的Set<WatchIdType>,对每个 watchId:- 调用
WatchFunc.execWatchById(watchId, 'count') - 从静态 Map 中通过 WeakRef 获取
WatchFunc实例 - 如果获取成功,调用
watchFunc.execute('count') - 如果获取失败(说明已被 GC),返回 false,然后从 Set 中删除这个 watchId
- 调用
这就是所谓的 lazy delete:不是主动清理失效的订阅,而是执行时发现失效才删。
2、为什么用 WeakRef + FinalizationRegistry
这个设计主要是为了避免内存泄漏。如果 SubscribedWatches 直接持有 WatchFunc 的强引用,而 WatchFunc 又可能持有组件引用,就会形成循环引用,导致对象无法被 GC。
用 WeakRef 的好处是:如果组件销毁了,WatchFunc 可以被 GC,WeakRef 会自动失效。但问题是,静态 Map 中的 WeakRef 条目不会自动删除,所以需要 FinalizationRegistry 在对象被 GC 时清理。
不过这里有个问题:SubscribedWatches.subscribers_ 里存的是 Set<WatchIdType>(普通值),不是 WeakRef。这意味着即使 WatchFunc 被 GC 了,watchId 还在 Set 里,直到执行时发现失效才删除。
3、双重存储的问题
WatchFunc 的静态 Map 存一份(WeakRef),SubscribedWatches.subscribers_ 存另一份(watchId 值)。两处存储,但清理时机不同步:
- 静态 Map 的清理:依赖 FinalizationRegistry,对象被 GC 时触发
subscribers_的清理:依赖 lazy delete,执行时发现失效才删
如果组件频繁创建销毁,subscribers_ 中可能会积累很多无效的 watchId。虽然不会造成内存泄漏(因为 WatchFunc 本身可以被 GC),但会影响查找效率。
4、执行时的查找开销
每次状态变化时,都要通过 watchId 从静态 Map 中查找 WatchFunc 实例。这个查找是 O(1) 的,但需要:
- 从 Map 中获取 WeakRef
- 调用
WeakRef.deref()获取实际对象 - 检查对象是否存在
如果 WatchFunc 已被 GC,deref() 返回 undefined,然后从 subscribers_ 中删除这个 watchId。这个开销虽然不大,但如果订阅列表很长,累积起来还是有影响的。
四、@Watch 的局限性
看完源码后,我发现 @Watch 有几个明显的限制。这些限制在使用时可能不明显,但确实存在:
1. 只能监听单个变量
这是最明显的限制。每个 @Watch 只能绑定一个状态变量:
typescript
@State @Watch('onAppleChange') apple: number = 0;
@State @Watch('onOrangeChange') orange: number = 0;
onAppleChange(): void { /* ... */ }
onOrangeChange(): void { /* ... */ }
如果多个变量变化时要做同一件事,就得写多个回调,或者在一个回调里手动检查其他变量。
2. 无法获取变化前的值
@Watch 的回调函数只接收属性名(string),不接收变化前后的值。如果你需要对比变化,得自己想办法保存旧值:
typescript
private oldCount: number = 0;
onCountChange(): void {
const newCount = this.count;
console.log(`count changed from ${this.oldCount} to ${newCount}`);
this.oldCount = newCount;
}
这种方式容易出错,而且每个变量都要单独维护旧值。
3. 内存管理的复杂性
虽然用了 WeakRef 和 FinalizationRegistry,但实际清理是 lazy 的。也就是说,如果组件销毁了,相关的 watchId 不会立即从订阅列表中删除,而是等到下次执行时发现失效才清理。
这意味着,如果大量组件频繁创建销毁,订阅列表中可能会积累很多无效的 id。虽然不会造成内存泄漏(因为 WatchFunc 本身可以被 GC),但会影响查找效率。
看源码会发现,SubscribedWatches.executeOnSubscribingWatches() 的实现是这样的:
typescript
public executeOnSubscribingWatches(propertyName: string): void {
this.subscribers_.forEach((watchId: WatchIdType) => {
if (!WatchFunc.execWatchById(watchId, propertyName)) {
// lazy delete:执行时发现失效才删除
this.subscribers_.delete(watchId);
}
});
}
每次执行都要遍历整个 subscribers_ Set,对每个 watchId 尝试查找并执行。如果 WatchFunc 已被 GC,execWatchById 返回 false,才从 Set 中删除。
这种设计的问题是:如果组件销毁后,状态变量很长时间不变化,那些无效的 watchId 就会一直留在 Set 里。虽然不会造成内存泄漏,但会浪费查找时间。
而且,FinalizationRegistry 的清理时机是不确定的。JavaScript 的 GC 是异步的,FinalizationRegistry 的回调什么时候执行,取决于 GC 的时机。所以静态 Map 中的 WeakRef 条目可能不会立即清理。
4. 执行顺序不确定
@Watch 的回调是通过 Set 遍历执行的,Set 的遍历顺序是不确定的。如果多个 @Watch 之间有依赖关系,可能会出问题。
看源码,SubscribedWatches.subscribers_ 是一个 Set<WatchIdType>。当状态变化时,框架会遍历这个 Set,对每个 watchId 执行对应的回调。但 Set 的遍历顺序在 JavaScript 中是不确定的(虽然实际实现可能按插入顺序,但规范不保证)。
如果你有两个 @Watch 回调,它们之间有依赖关系(比如一个回调依赖另一个回调的执行结果),那执行顺序就不可控了。这在调试时会很麻烦,因为每次执行顺序可能都不一样。
而且,@Watch 的回调是同步执行的,没有异常处理。如果某个回调抛异常,可能会影响后续回调的执行(取决于具体实现)。这也是一个潜在的问题。
5. 不保证回调一定会执行
这是从源码中发现的一个细节。如果 WatchFunc 实例已被 GC,execWatchById 会返回 false,回调就不会执行。虽然这种情况不常见(因为组件还在时,WatchFunc 通常不会被 GC),但在某些极端情况下(比如内存压力大,GC 很频繁),可能会出现回调"丢失"的情况。
看源码,WatchFunc.execWatchById() 的实现:
typescript
public static execWatchById(watchId: WatchIdType, propertyName: string): boolean {
const weak = WatchFunc.watchId2WatchFunc.get(watchId);
const watchFuncOpt = weak?.deref();
if (watchFuncOpt && watchFuncOpt instanceof WatchFunc) {
watchFuncOpt!.execute(propertyName);
return true;
} else {
return false; // WatchFunc 已被 GC,返回 false
}
}
如果 WatchFunc 已被 GC,deref() 返回 undefined,函数直接返回 false,回调不会执行。虽然这种情况很少见,但确实存在。
五、@Monitor 的优势
对比 @Watch,@Monitor 在几个方面都更合理:
1. 支持多变量监听
一个 @Monitor 可以同时监听多个变量:
typescript
@Monitor('apple', 'orange')
onFruitChange(monitor: IMonitor) {
monitor.dirty.forEach((name: string) => {
console.log(`${name} changed`);
});
}
这样就不需要为每个变量单独写回调了。
2. 可以获取变化前后的值
这是 @Monitor 最实用的特性:
typescript
@Monitor('count')
onCountChange(monitor: IMonitor) {
const before = monitor.value()?.before;
const now = monitor.value()?.now;
console.log(`count changed from ${before} to ${now}`);
}
不需要手动维护旧值,框架帮你处理了。
3. 更清晰的依赖追踪
@Monitor 的实现基于依赖追踪系统,和 @Computed 用的是同一套机制。这意味着:
- 依赖关系更清晰
- 执行顺序更可控
- 性能优化空间更大
4. 批量更新和去重
@Monitor 支持批量更新和去重。如果多个监听的变量同时变化,回调只会执行一次,而不是多次。这在处理复杂状态时很有用。
六、迁移建议
官方文档已经给出了迁移示例,这里补充几个实际使用中的注意点:
1、单变量场景
如果只是简单的单变量监听,直接替换就行:
typescript
// V1
@State @Watch('onCountChange') count: number = 0;
onCountChange(): void { /* ... */ }
// V2
@Local count: number = 0;
@Monitor('count')
onCountChange(monitor: IMonitor) { /* ... */ }
注意 V2 中要用 @Local 而不是 @State(V2 的状态管理装饰器不同)。
2、多变量场景
这是 @Monitor 的优势场景:
typescript
// V1:需要多个 @Watch
@State @Watch('onAppleChange') apple: number = 0;
@State @Watch('onOrangeChange') orange: number = 0;
// V2:一个 @Monitor 搞定
@Local apple: number = 0;
@Local orange: number = 0;
@Monitor('apple', 'orange')
onFruitChange(monitor: IMonitor) {
monitor.dirty.forEach((name: string) => {
const value = monitor.value(name);
console.log(`${name}: ${value?.before} -> ${value?.now}`);
});
}
3、需要变化前值的场景
如果业务逻辑需要对比变化前后的值,@Monitor 是唯一选择:
typescript
@Monitor('price', 'discount')
onPriceChange(monitor: IMonitor) {
monitor.dirty.forEach((name: string) => {
const value = monitor.value(name);
if (name === 'price' && value?.before !== value?.now) {
// 价格变化了,可能需要重新计算
this.recalculateTotal();
}
});
}
七、总结
从源码角度看,@Watch 的实现虽然解决了内存泄漏问题,但设计上确实有些复杂,而且功能受限。@Monitor 基于更成熟的依赖追踪系统,功能更强,使用也更灵活。
如果你还在用 V1 的 @Watch,建议尽快迁移到 V2 的 @Monitor。不只是因为官方推荐,而是它确实更好用。
当然,如果项目还在用 V1 的装饰器系统,那暂时还用不了 @Monitor。但新项目建议直接用 V2,少走弯路。
八、最后
如果您有任何疑问、对文章写的不满意、发现错误、想吐槽或者有更好的想法,欢迎在评论、私信或邮件中提出,非常感谢您的支持。🙏
谢谢读者姥爷