竞态问题通常在多进程或多线程编程中被提及,前端很少讨论到,但在日常工作中会遇到与竞态问题相似的场景,例如:
let finalData
watch(obj, async ()=>{
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果复制给data
finalData = res
})
上面的代码,咋一看没什么问题。但仔细思考会发现这段代码会发生竞态问题。如果在修改obj对象的某个字段值,会导致回调函数执行并发送第一次请求A,要是在请求A的结果返回之前,对obj对象的某个字段进行了第二次修改,这会导致发送第二次请求B。此时请求A和B都在进行中,哪一个请求会先返回结果,我们不确定。如果请求B先于请求A返回结果,就会导致最终finalData中存储的是A请求的结果。但是希望变量finalData存储的值应该是由请求B返回的结果。
其实解决思路可以是这样把后一次请求B的结果视为“最新的”,把请求A的视为“过期”的,其请求结果也就视为无效的。通过这种方式可以避免竞态问题导致的错误结果。其实就是需要一个让副作用过期的手段。下面就拿Vue.js作例子,看看Vue.js是怎么解决这个问题的:
在Vue.js中,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的原理是什么?其实就是在watch内部每次检测到变更后,在副作用函数重新执行前,会调用通过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中 (fn)
cleanup = fn
}
const job = ()=>{
newVlue = effectFn()
// 在调用回调函数cb之前,先调用过期回调
if(cleanup){
cleanup()
}
//将 onInvalidate作为回调函数的第三个参数,以便用户使用
cb(oldValue, newValue, onInvalidate)
oldValue = newValue
}
const effectFn = effect(
()=> getter(),
{
lazy: true,
scheduler: ()=>{
if(options.flush === 'post'){
const p = Promise.resolve()
p.then(job)
}else{
job()
}
}
}
)
if(options.immediate){
job()
}else{
oldValue = effectFn()
}
}
这里关键点在job函数内,每次执行回调函数cb之前,先检查是否存在过期回调,如果存在,则执行过期回调函数cleanup。最后把onInvalidate函数作为回调函数的第三个参数传递给cb,以便用户使用。