过期的副作用
竞态问题
竞态问题比较常见于多线程或者多进程编程中,所以前端领域很少讨论这个问题。但是在开发中,这个问题也是挺常见的:
let finalData
watch(obj, async () => {
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果赋值给 data
finalData = res
})
这段代码中,我们用watch观测obj的变化,每次变化都会发一次请求,然后把finalData的值重置为请求的结果。这一个逻辑乍一看似乎是一个正常的watch逻辑。但是仔细想一想,fetch伴随的网络请求可能会伴随着网络阻塞等种种因素,那么进而可能产生竞态问题。
假设我前后做了A,B两次修改,按照watch的回调函数那么会发送A,B两次请求。倘若没有额外处理,那么从代码看就会是谁的请求晚回来,finalData就会是哪个。例如下面这个状况就会把finalData设给A。
然而这并不合理,理想状态下,我们希望finalData是B请求返回的数据,不论哪个比较晚回来。所以我们需要一个让副作用过期的手段。
已有的解决方案
那么在vue中,已经有一个现成的方案:watch函数中的第三个函数onInvalidate
,他是一个函数,类似于事件监听器,当过期事件触发的时候,就会调用onInvalidate
中注册的函数,使前一个副作用过期。
watch(obj, async (newValue, oldValue, onInvalidate) => {
// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
let expired = false
// 调用 onInvalidate() 函数注册一个过期回调
onInvalidate(() => {
// 当过期时,将 expired 设置为 true
expired = true
})
// 发送网络请求
const res = await fetch('/path/to/request')
// 只有当该副作用函数的执行没有过期时,才会执行后续操作。
if (!expired) {
finalData = res
}
})
让我们对上面那段代码进行分析:当使用上面代码重新进行设置,我们来分析在会触发的情况(也就是第一次设置的网络请求比第二次设置的网络请求慢的情况下)下onInvalidate
是如何使前边的副作用过期:
假设第一次修改obj的请求为A,第二次修改obj的请求为B。那么我们现在假设请求A返回来比请求B晚,如上图所示。那么在第二次请求回来的时候会触发回调设置finalData,于此同时,通过触发onInvalidate
设置第一次的expired为true,那么在请求A回来的时候并不会修改finalData。
vue是如何实现onInvalidate的
onInvalidate
的大致原理如下:
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// cleanup 用来存储用户注册的过期回调
let cleanup
// 定义 onInvalidate 函数
function onInvalidate(fn) {
// 将过期回调存储到 cleanup 中
cleanup = fn
}
const job = () => {
newValue = effectFn()
// 在调用回调函数 cb 之前,先调用过期回调
if (cleanup) {
cleanup()
}
// 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
cb(newValue, oldValue, onInvalidate)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
就是在wath函数中定义了一个onInvalidate的函数,内部逻辑就是将全局的cleanup设置为传进来的函数参数。那么在触发的副作用函数job中增加一个if分支:当存在cleanup的时候,执行cleanup再执行回调函数。
可能会有一点点抽象,其实cleanup是存在于watch对象中的一个内部变量,那么在回调函数中必须要执行onInvalidate才能进行过期副作用。还是以请求A和请求B的例子来讲,当请求A设置完之后,此时cleanup已经被赋值
cleanup = () => {
expired = true;
}
那么在B设置的时候,那么cleanup会被执行,那么此时的expired在作用域链中,指向的是请求A的函数栈,那么此时对于请求A的回调函数中,expired已经为true了。至此,我们成功让A过期了。