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

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

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

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)。git

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

正常状况下,咱们看到的截图是这样: ajax

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

截图以下: axios

image

分析上面请求过程:api

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

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

再来看一个需求:在输入框中,增长联想功能,在用户输入的过程当中进行 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 的回调会接收到的第三个参数是一个用来注册清理操做的函数。即:一个异步操做在完成以前数据就产生了变化,咱们可能要撤销还在等待的前一个操做。嗯???这不正好与咱们上面所提到的需求很相似,你们能够从尤大的文章寻找更好的方案。

经过上面两个方向的探讨,咱们发现两种方案均可以免数据错乱的状况发生。两种方案也不只在这种需求的状况下可使用,一样也能够在避免用户屡次点击提交,屡次下载等需求状况下调整使用。固然这算是一个优化点。文章中若有错误,请指正,谢谢!!!

相关文章
相关标签/搜索