Vue3源码学习4(下) | 响应系统的作用与实现
Vue3源码学习4(下) | 响应系统的作用与实现
继上面对于
Vue3
响应系统原理的学习,我们对这部分内容有了较深的理解,接下来让咱再次起航攻破这座大山
吧!
4.6 避免无限递归循环
产生无限递归循环的原因
Vue
发展至此,想要实现一个完善的 响应系统 需要考虑诸多细节。而现在要介绍的无限递归循环
就是其中之一,咱们还是先看代码:
const data = { foo:1 }
const obj = new Proxy(data,{ /*...*/ })
effect(() => obj.foo++)
乍一看,上面的代码似乎并没有什么问题,就是在effect
注册的副作用函数内有一个自增操作obj.foo++
,该操作会引发 栈溢出:
Uncaught RangeError:Maximum call stack size exceeded
是不是觉得很莫名其妙,就在前几节中我们说的都很"合情合理"
。但是怎么到这就整出这个幺蛾子呢?接下来,就来一起来瞅瞅吧!
实际上,我们可以把obj.foo++
这个自增操作分开来看,它相当于:
effect(() => {
// 语句
obj.foo = obj.foo + 1
})
分析:为啥会 栈溢出?
在这句语句中,既会读取obj.foo的值,又会设置obj.foo的值,而这就是导致问题的根本原因。我们可以尝试推理一下代码的执行流程:
- 首先读取obj.foo的值,这会触发track操作,将当前副作用函数收集到一个变量中。
- 接着将其加1后再赋值给obj.foo,此时会触发trigger操作,即把变量中的副作用函数取出并执行。
问题所在:
该副作用函数正在执行,还没执行完毕,就要开始下一次的执行。
这样会导致无限递归地调用自己,于是就产生了 栈溢出。
解决办法
解决办法并不难,通过上面的分析可以知道:读取和设置操作是在同一个副作用函数内进行的。
也就是说,此时无论是track
时收集的副作用函数,还是trigger
时要触发执行的副作用函数都是 activeEffect。
基于此,我们可以在trigger动作发生时增加守卫条件
:如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
代码如下:
function trigger(target,key) {
const depsMap = bucket.get(target)
if(!depsMap) retrun
const 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 调度执行
代码是我们写的,我们当然是它们的
支配者
可调度性是响应系统非常重要的特性。首先我们需要知道一下这玩意是什么。
什么是可调度性
所谓的可调度性
,指的是当trigger
动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。
首先,看一下如何决定副作用函数的执行方式,下面代码所示:
const data = { foo:1 }
const obj = new Proxy(data,{ /*...*/ })
effect(() => {
console.log(obj.foo++)
})
obj.foo++
console.log("结束了")
在副作用函数中,我们首先
使用 console.log 语句打印obj.foo的值,接着
对obj.foo执行自增操作,最后
使用console.log语句打印出'结束了'。这段代码的输出结果如下:
1
2
'结束了'
现在假设需求有变,输出顺序需要调整为:
1
'结束了'
2
看到上面的打印结果,你是不是会想到只要调整一下代码的位置
即可。
那么,有没有什么办法能够在不调整代码的情况下实现需求呢?这时就需要 响应系统支持调度
为effect
函数设计一个选项参数options
,允许用户指定 调度器
effect(
() => {
console.log(obj.foo)
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// ...
}
}
)
如上面的代码所示,用户在调用effect函数注册副作用函数时,可以传递第二个参数options
。它是一个对象,其中允许指定scheduler调度函数
。
调度器的作用
在effect函数内部需要把options选项挂载到对应的副作用函数上:
function effect(fn,options = {}) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之间将当前副作用函数压栈
effectStack.push(effect)
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) return
const 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()
}
})
}
如上面的代码所示,在trigger动作触发副作用函数执行
时,我们优先
判断该副作用函数是否存在调度器
,如果存在,则直接调用
调度器函数,并把当前副作用函数作为参数
传递过去,由用户自己控制如何执行;否则保留当前的行为,即直接执行副作用函数。
调度器的实操
有了这些基础设施后,咋现在就可以实现前文的需求,如下面的代码所示:
const data = { foo:1 }
const obj = new Proxy(data,{ /*...*/ })
effect(() => {
console.log(obj.foo++)
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// 副作用函数放到宏任务队列中执行
setTimeout(fn)
}
}
)
obj.foo++
console.log("结束了")
我们使用setTimeout开启了一个宏任务
来执行副作用函数fn,这样就能实现期望的打印顺序了:
(什么是宏任务?可以自行学习一下事件循环
)
1
'结束了'
2
这是调度器控制副作用函数的执行顺序
通过调度器还可以做到控制它的执行次数,这一点也尤为重要。思考下面的例子:
const data = { foo:1 }
const obj = new proxy(data,{ /*...*/ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
onj.foo++
在没有指定调度器的情况下,它的输出如下:
1
2
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(() => {
// 结束后重置 isFlushing
isFlushing = false
})
}
effect(() => {
console.log(obj.foo)
},
{
scheduler(fn) {
// 每次调度时,将副作用函数添加到 jobQueue 队列中
jobQueue.add(fn)
// 调用 flushJob 刷新队列
flushJob()
}
}
)
obj.foo++
obj.foo++
观察上面的代码:
- 首先,定义了一个
任务队列jobQueue
,它是一个Set数据结构,目的是利用Set数据结构的自动去重
能力。 - 接着,看调度器
scheduler的实现
,在每次调用执行时,先将当前副作用函数添加到jobQueue队列中,再调用flushJob函数刷新新队列
。 - 然后,把目光转向
flushJob函数
,该函数通过isFlushing标致判断是否需要执行,只有当其为false时才需要执行,而一旦flushJob函数开始执行,isFlushing标志就会设置为true,意思是无论调用多少次flushJob函数,在一个周期内都只会执行一次
注意:在 flushJob
内通过 p.then
将一个函数添加到微任务队列,在微任务队列内完成对 jobQueue
的遍历执行。
上面代码的核心效果:
-
连续对obj.foo执行两次自增操作,会同步且连续地两次执行scheduler调度函数,这意味着同一个副作用函数会被jobQueue.add(fn)语句添加两次,但由于Set数据结构的去重能力,最终jobQueue中只会有一项,即当前副作用函数
-
类似地,flushJob也会同步且连续地执行两次,但由于isFlushing标志存在,实际上flushJob函数在一个事件循环内只会执行一次,即在微任务内执行一次。
-
当微任务队列开始执行时,就会遍历 jobQueue 并执行里面存储的副作用函数。由于此时jobQueue队列内只有一个副作用函数,所以只会执行一次,并且当它执行时,字段obj.foo的值已经是3了,这样我们就实现了期望的输出:
1
2
可能你已经注意到了,这个功能有点类似于在 Vue.js 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue.js 内部实现了一个更加完善的调度器,思路与上文介绍相同
待续
加油!各位!
转载自:https://juejin.cn/post/7159830145262419999