目录
4.6 避免无限递归循环
4.7 调度执行
4.8 计算属性 computed 与 lazy
4.9 watch 的实现原理
4.10 立即执行的 watch 与回调执行时机
4.11 过期副作用与竞态问题
总结
4.6 避免无限递归循环
在实现完善响应式系统时,需要注意避免无限递归循环。以以下代码为例:
const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })effect(() => obj.foo++) // 既会读取 obj.foo 的值,又会设置 obj.foo 的值
上述代码,effect 注册的副作用函数会触发栈溢出。为什么呢?
其实,我们可以将 obj.foo++ 分解为看作是两个步骤:读取 obj.foo 的值并给它增加 1:
effect(() => {// 语句obj.foo = obj.foo + 1
})
上述代码,我们首先读取 obj.foo 的值,触发数据追踪(track)操作,将当前的副作用函数添加到依赖列表。
然后,我们对 obj.foo 赋值,这会触发触发器(trigger)操作,从依赖列表中取出并执行所有的副作用函数。
这就引发了问题,因为我们正在执行的副作用函数还没结束,就开始了下一次的执行,从而导致了无限递归调用,最终引发栈溢出。
解决办法是在 trigger 动作发生时增加守卫条件,如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。代码如下:
function trigger(target, key) {const depsMap = bucket.get(target)if (!depsMap) returnconst effects = depsMap.get(key)const effectsToRun = new Set()effects && effects.forEach(effectFn => {// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行if (effectFn !== activeEffect) { // 新增effectsToRun.add(effectFn)}})effectsToRun.forEach(effectFn => effectFn())
}
通过这种方式,我们可以避免无限递归调用和栈溢出。
4.7 调度执行
调度性是响应式系统的重要特性,它允许我们决定副作用函数执行的时机、次数和方式。以以下的代码为例:
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })effect(() => {console.log(obj.foo)
})obj.foo++console.log('结束了')
现在,假设我们希望改变输出顺序,但不改变代码结构。这就需要在响应系统中支持调度。
为了实现可调度性,我们可以为 effect 函数添加一个选项参数 options,允许用户指定调度器:
effect(() => {console.log(obj.foo)},{scheduler(fn) {// ...}}
)
在调用 effect 函数注册副作用函数时,用户可以传入第二个参数 options。
这是一个对象,可以指定 scheduler 调度函数。同时,我们需要将 options 选项绑定到对应的副作用函数上:
function effect(fn, options = {}) {const effectFn = () => {cleanup(effectFn)// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffectactiveEffect = effectFn// 在调用副作用函数之前将当前副作用函数压栈effectStack.push(effectFn)fn()// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值effectStack.pop()activeEffect = effectStack[effectStack.length - 1]}// 将 options 挂载到 effectFn 上effectFn.options = options // 新增// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合effectFn.deps = []// 执行副作用函数effectFn()
}
有了 调度函数,我们在 trigger 函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:
function trigger(target, key) {const depsMap = bucket.get(target)if (!depsMap) returnconst effects = depsMap.get(key)const effectsToRun = new Set()effects &&effects.forEach(effectFn => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn)}})effectsToRun.forEach(effectFn => {// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递if (effectFn.options.scheduler) {effectFn.options.scheduler(effectFn) // 新增} else {// 否则直接执行副作用函数(之前的默认行为)effectFn() // 新增}})
}
这样,当触发副作用函数时,我们首先检查副作用函数是否有调度器。
如果有,我们调用调度器函数,并将当前的副作用函数作为参数传递,由用户自己控制执行方式;
否则,我们保持默认行为,即直接执行副作用函数。
有了上面基础设施的支持下,我们使用 setTimeout 开启一个宏任务来执行副作用函数 fn,这样就能更灵活控制代码的执行顺序了。
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })effect(() => {console.log(obj.foo)},{scheduler(fn) {setTimeout(fn)}}
)obj.foo++console.log('结束了')
输出结果:
1
'结束了'
2
通过调度器,我们还可以控制副作用函数的执行次数。这是一个重要的特性,如下所示:
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })effect(() => {console.log(obj.foo)
})obj.foo++
obj.foo++
在这个例子中,obj.foo 的值从 1 增加到 3,2 只是过渡状态。
如果我们只关心最终结果而不关心过程,那么打印过渡状态就是多余的。我们希望输出:'1','3'。基于调度器,我们可以轻松实现:
// 定义一个任务队列
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve()// 一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob() {// 如果队列正在刷新,则什么都不做if (isFlushing) return// 设置为 true,代表正在刷新isFlushing = true// 在微任务队列中刷新 jobQueue 队列p.then(() => {jobQueue.forEach(job => job())}).finally(() => {// 结束后重置 isFlushingisFlushing = false})
}effect(() => {console.log(obj.foo)},{scheduler(fn) {// 每次调度时,将副作用函数添加到 jobQueue 队列中jobQueue.add(fn)// 调用 flushJob 刷新队列flushJob()},}