
到目前为止,我们的 effect 会在依赖的数据发生变化时,立即重新执行。 这种简单直接的模式在很多情况下都有效,但当遇到密集且连续的数据变更时,它可能会引发不必要的性能问题。
为什么需要 Effect 调度器?
JavaScript
const count = ref(0)
effect(() => {
console.log('渲染组件:', count.value)
// 复杂的 DOM 操作...
})
// 连续修改
count.value = 1 // 触发渲染
count.value = 2 // 又触发渲染
count.value = 3 // 再次触发渲染
在上方案例中我们可以看到,如果 effect 中包含复杂的 DOM 操作,连续的赋值会造成三次重新渲染。但实际上,我们往往只需要最后一次变更的结果,这时候就需要调度器来优化这个过程。
什么是 Effect 调度器?
调度器是一个控制 effect 执行时机的机制:
- 没有调度器:数据变化 → 立即执行 effect
- 有调度器:数据变化 → 调度器决定何时/如何执行 effect
特性
避免同步连续触发多次更新
JavaScript
// 避免同步连续触发多次更新
const scheduler = (job) => {
Promise.resolve().then(job) // 在下一个微任务中执行
}
effect(() => {
console.log(count.value)
}, { scheduler })
count.value = 1 // 不会立即执行
count.value = 2 // 不会立即执行
count.value = 3 // 只有最后一次的变更会在微任务中执行
Vue 组件更新调度
JavaScript
effect(() => {
// 组件渲染逻辑
}, {
scheduler: queueJob // 加入更新队列,而不是立即更新
})
防抖、节流
JavaScript
const debounceScheduler = debounce((job) => job(), 100)
effect(() => {
// 高频触发的逻辑
}, { scheduler: debounceScheduler })
调度器用法
JavaScript
import { ref, effect } from '../dist/reactivity.esm.js'
const count = ref(0)
effect(() => {
console.log('Effect', count.value)
}, {
scheduler() {
console.log('触发调度器')
}
})
setTimeout(() => {
count.value = 1
}, 1000)
当前效果
arduino
Effect 0
// 1秒后
Effect 1
预期效果
使用调度器后,setTimeout
中的赋值不再直接触发 effect 的执行:
arduino
Effect 0
// 1秒后
触发调度器
Class 类知识补充
要实现这个可选的调度器,我们需要利用 JavaScript Class 的特性。
我们先来补充这个知识点:
- 一般的类
JavaScript
class Person {
constructor(name) {
this.name = name
}
sayHi() {
console.log('我是原型方法', this.name)
}
}
const p = new Person('张三')
p.sayHi()
// 输出:我是原型方法 张三
- 实例属性覆盖原型方法
当实例上存在与原型链上同名的方法时,会优先调用实例上的方法。
JavaScript
class Person {
constructor(name) {
this.name = name
}
sayHi() {
console.log('我是原型方法', this.name)
}
}
const p = new Person('张三')
// 在实例 p 上直接定义一个 sayHi 方法
p.sayHi = function() {
console.log('我是实例属性', this.name)
}
p.sayHi()
// 输出:我是实例属性 张三
实现调度器
要求
- 当依赖更新时,如果用户提供了
scheduler
,则执行scheduler
。 - 如果没有传入
scheduler
,仍然要执行run()
。
实现思路
- 用户传入的
scheduler
是一个可选方法。 - 当用户传入时,我们可以利用"实例属性覆盖原型方法"的特性,将其附加到
ReactiveEffect
实例上。 - 为了保证更新的入口稳定,我们新建一个
notify
方法,由它来决定是调用实例上的scheduler
还是原型上的scheduler
。
TypeScript
export let activeSub;
export class ReactiveEffect {
constructor(public fn: Function) {}
run() {
const prevSub = activeSub
activeSub = this
try {
return this.fn()
} finally {
activeSub = prevSub
}
}
/*
* 如果依赖数据发生变化,由此方法通知更新。
*/
notify() {
this.scheduler()
}
/*
* 默认的调度器,直接调用 run 方法。
* 如果用户传入了自定义的 scheduler,它会作为实例属性覆盖掉这个原型方法。
*/
scheduler() {
this.run()
}
}
export function effect(fn, options) {
const e = new ReactiveEffect(fn)
// 将 options (包含 scheduler) 合并到 effect 实例上
Object.assign(e, options)
e.run()
/*
* 绑定 this,确保 runner 函数在外部被调用时,
* 内部的 this 依然指向 effect 实例 e。
* 如果直接 return e.run,会丢失 this 上下文。
*/
const runner = e.run.bind(e)
// 将 effect 实例挂载到 runner 函数上,方便外部访问
runner.effect = e
return runner
}
相应地,propagate
函数中更改为执行 notify
方法:
TypeScript
export function propagate(subs) {
// ...
// 更改为执行 notify 方法
// 因为 scheduler 方法可能会被用户覆盖,
// 因此使用 notify 作为稳定的更新入口。
queuedEffect.forEach(effect => effect.notify())
}
丢失 this
是指什么?
如果直接返回 e.run
会发生什么?这就涉及到了 this
指向问题。
请参考下方示例:
JavaScript
export function effect(fn, options) {
const e = new ReactiveEffect(fn)
Object.assign(e, options)
e.run()
return e.run // 丢失 this
}
// 使用时
const runner = effect(() => console.log('effect'))
runner() // 错误! 此时 run 方法内部的 this 是 undefined 或 window
图解 notify()
执行步骤

执行原型方法 (左)
这是 effect 的默认行为,当我们这样使用它时:effect(() => { ... })
- 数据变化 →
propagate
:当响应式数据的值被修改时,会触发其 setter,最终由propagate
函数开始遍历依赖该数据的effect
。 propagate
→effect.notify()
:在propagate
的循环里面,我们统一调用effect.notify()
,让它作为更新的固定入口点。effect.notify()
→scheduler()
:notify()
内部会去调用this.scheduler()
。在这种情况下,因为我们创建effect
时没有提供任何 options,所以effect
实例上并不存在 自己的scheduler
属性。scheduler()
→run()
:根据 JavaScript 的原型链规则,它会去寻找ReactiveEffect
原型上的scheduler()
方法。我们默认的scheduler()
方法,就是直接调用this.run()
。因此,effect 的核心逻辑被立即执行。
使用调度器 (右)
- 数据变化 →
propagate
→effect.notify()
:前两个步骤和原型状况完全相同:数据变更,propagate
遍历并调用effect.notify()
。 effect.notify()
→scheduler()
:notify()
内部会调用this.scheduler()
。但关键在于,这次我们在创建effect
时,通过options
传入了一个自定义的scheduler
函数。scheduler()
→ 用户的调度器逻辑 :Object.assign(e, options)
会把用户的scheduler
函数作为一个实例属性 附加到effect
对象上。根据 JavaScript 的优先级规则,实例属性高于原型方法。因此,程序在effect
实例上直接找到这个自定义的scheduler
并执行它,而不会再去查找原型链。
今天,我们通过引入调度器,将 effect 的核心逻辑(做什么)与执行策略(何时做)进行了分离。其中 fn
负责"做什么",而 scheduler
负责决定"什么时候做"。
想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。