如何解决异步请求的竞态问题

6,021 阅读7分钟

我们都知道JavaScript只有一根线程,相较同步操作,异步彻底避免了线程阻塞,提高了线程的可响应性。但是,与此同时我们会发现一个问题:无法保证异步操作的完成会按照他们开始时同样的顺序。

简单说,异步操作的开始顺序并不决定结束顺序,一个简单例子如下:

let pro_1 = new Promise((resolve, rejct) => {
  setTimeout(() => {
    resolve("pro_1");
  });
});
let pro_2 = new Promise((resolve, rejct) => {
  resolve("pro_2");
});

pro_1.then(res => {
  console.log(res);
});

pro_2.then(res => {
  console.log(res);
});

// pro_2
// pro_1

以上这种情况呢,虽说是用setTimeout引起了执行顺序的变化,但是这种情况我们可以姑且称为可控竞态,因为这个完全是由JavaScript自身的执行机制导致(也不算是其问题吧)。关于异步,在这里可以给大家推荐两篇文章,更好的学习一下JavaScript自身的执行机制(这一次,彻底弄懂 JavaScript 执行机制Tasks, microtasks, queues and schedules)。

除了JavaScript执行机制的顺序,在我们实际开发的过程中,异步请求开始到请求结束的顺序就无法得到控制了。如:现在有个需求,某订单列表的查询,需要直接通过tab切换来获取列表信息。

正常情况下,我们看到的截图是这样:

image
当我们模拟网络较差环境下(Network -> Offline右边列表切换为Slow 3G)请求接口时,则出现了这种情况,显而易见,列表出现了错乱的情况。

截图如下:

image

分析上面请求过程:

  1. 状态初始化为A
  2. 点击进行切换,状态变为B
  3. 请求接口获取数据
  4. 异步请求成功,展示数据

在这个过程中,是哪一个步骤出错了呢?首先在 步骤2 切换的时候,若初始化(请求接口)成功了,则正常显示,那如果在切换的时候,上一个请求还没有返回数据,又进行了接口请求, 此时我们便无法控制是上一次请求先完成还是当前请求先完成,若上一次请求最后完成,那我们之前返回数据显然会被覆盖,引起数据错乱。

再来看一个需求:在输入框中,增加联想功能,在用户输入的过程中进行 api 接口请求,同样我们可以为输入的过程增加防抖或节流的方式进行异步请求,但依旧无法保证返回结果与输入内容对应起来。

通过上面两个需求可以发现两个问题:频繁进行异步请求和请求成功后无法保证返回结果与之前状态做对应,我们可以分为两个方向进行探讨:

  • 避免多次请求
  • 请求前后状态做关联

避免多次请求

在与服务端异步请求过程中,某些用户操作会频繁请求资源,而此时会造成一定的影响,为了避免多次请求,我们可以做以下几点方案避免:

方案1. 按照同步的方式进行提交,在当前请求完成(成功或失败)后,再进行下一次请求
if (this.pendding) return 

this.pendding = true

api().then(res => {
    this.pendding = false
}).catch(error => {
    this.pendding = false
})
方案2. 按照最后一次请求为标准,abort之前所有请求
if (this.pendding) {
    this.ajax.abort()
}
this.pendding = true
this.ajax = $.ajax({})

读到这里,可能会有小伙伴想到:如何中止正在进行的请求呢?我们可以根据不同情况先的请求介绍几种方案:

  • abort原生的XMLHttpRequest
let xhr = new XMLHttpRequest(),
    method = "GET",
    url = "https://developer.mozilla.org/";
xhr.open(method,url,true);

xhr.send();

xhr.abort();
  • abort jQuery
let ajax = $.ajax({})
...
ajax.abort()
  • abort axios正在进行的请求

我们都知道 axios 是基于 promise 进行封装的,那我们不妨再想一下:如何去中止 promise 的执行呢?

首先创建一个 promise 的例子:

let promise = new Promise((resolve, reject) => {
  resolve('success')
})
promise.then(res => {
  console.log(res, 'then_1')
  return res
}).then(res => {
  console.log(res, 'then_2')
}).catch(error => {
  console.error(error)
})

若此时,想要中止 then_2 的输出,我们又该怎么办呢?

(1). 通过 thro w或者 Promise.reject()

promise.then(res => {
  console.log(res, 'then_1')
  // throw new Error('中止当前promise')
  return Promise.reject({error: '中止当前promise'})
}).then(res => {
  console.log(res, 'then_2')
}).catch(error => {
  console.error(error)
})

此时又发现,主动抛出的错误和系统的报错无法区分,所以需要在主动抛出的错误做一下标示;

promise.then(res => {
  console.log(res, 'then_1')
  // let e = new Error()
  // e.name = 'isInitiativeError'
  // e.message = true
  // throw e
  return Promise.reject({message: '中止当前promise', isInitiativeError: true})
}).then(res => {
  console.log(res, 'then_2')
}).catch(error => {
  if (error.isInitiativeError) {
    console.warn('主动中止!')
    return
  }
  if (error.name == 'isInitiativeError' && error.message) {
    console.warn('主动中止!')
    return
  }
  console.error(error)
})

诺,我们又发现,这种方式可以跳过 then 和第一个 catch 之间的操作,但是 catch 之后的 then,就没有办法中止了(或者是在 catch 里面继续 throw 或 Promise.reject(),确保 catch 之后一直保持进入下一个 catch,这样也是可以保证中止了 then,但是这样的写法过于繁琐),接下来可以通过第二个方法解决。

(2). 返回 new Promise() 通过保持 Promise 的 pending 状态,来保证操作无法继续往下走;

promise.then(res => {
  console.log(res, 'then_1')
  return new Promise((resolve, reject) => {
    console.log('半路杀出个promise')
  })
}).then(res => {
  console.log(res, 'then_2')
}).catch(error => {
  if (error.isInitiativeError) {
    console.warn('主动中止!')
    return
  }
  if (error.name == 'isInitiativeError' && error.message) {
    console.warn('主动中止!')
    return
  }
  console.error(error)
}).then(res => {
  console.log('then_3')
})

在回调函数结束后,promise 会释放函数引用;但是若 promise 始终保持 pending 状态,回调函数的内存将无法得到释放,会造成内存泄漏。(完美方案探索中...)

知道了如何中止 promise,我们又该如何 abort 正在进行 axios 请求呢?通过查询 axios 的文档,会发现它提供了取消的 api(使用 cancel token 取消请求),而 axios 的 cancel token API 正是基于cancelable promises proposal

可以使用 CancelToken.source 工厂方法创建 cancel token,像这样:

var CancelToken = axios.CancelToken;
var source = CancelToken.source();

axios.get('/user/12345', {
  cancelToken: source.token
}).catch(function(thrown) {
  if (axios.isCancel(thrown)) {
    console.log('Request canceled', thrown.message);
  } else {
    // 处理错误
  }
});

// 取消请求(message 参数是可选的)
source.cancel('Operation canceled by the user.');

还可以通过传递一个 executor 函数到 CancelToken 的构造函数来创建 cancel token:

var CancelToken = axios.CancelToken;
var cancel;

axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    // executor 函数接收一个 cancel 函数作为参数
    cancel = c;
  })
});

// 取消请求
cancel();

Note : 可以使用同一个 cancel token 取消多个请求

  • abort fetch已发出的请求

通过AbortController来 abort 正在进行的请求,但是目前 AbortController 的支持还是存在一定的兼容性,有兴趣的小伙伴可以了解下。

let  controller = null, signal = null
// 若存在,则直接中断之前请求
if (controller) {
  controller.abort()
}
if (AbortController) {
  controller = new AbortController();
  signal = controller.signal;
}
api().then(() =>{
  ......
})

请求前后状态做关联

上面提了那么多如何通过 abort 请求接口来避免造成的数据错乱问题,那么接下来,我们可以把请求前的状态与返回结果做关联,来保证正确的展示信息。首先记录异步请求开始的状态,在异步请求完成后进行状态的检验。

getList () {
  this.loading = true
  // 记录状态
  let _id = this.id

  api().then(() =>{
    // 若当前状态与记录状态不一样,则直接返回
    if (_id != this.id) return
    ...
  })
}

附:vue 3.0中 watch 的清理副作用

watch(idValue, (id, oldId, onCleanup) => {
  const token = performAsyncOperation(id)
  onCleanup(() => {
    // id 发生了变化,或是 watcher 即将被停止.
    // 取消还未完成的异步操作。
    token.cancel()
  })
})

今天刚好有看到尤大的关于vue3.0 RFC 的文章Vue Function-based API RFC,新的 api 中 watch 的回调会接收到的第三个参数是一个用来注册清理操作的函数。即:一个异步操作在完成之前数据就产生了变化,我们可能要撤销还在等待的前一个操作。嗯???这不正好与我们上面所提到的需求很类似,大家可以从尤大的文章寻找更好的方案。

通过上面两个方向的探讨,我们发现两种方案都可以避免数据错乱的情况发生。两种方案也不仅在这种需求的情况下可以使用,同样也可以在避免用户多次点击提交,多次下载等需求情况下调整使用。当然这算是一个优化点。文章中如有错误,请指正,谢谢!!!