如何解決異步請求的競態問題

咱們都知道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 的回調會接收到的第三個參數是一個用來註冊清理操做的函數。即:一個異步操做在完成以前數據就產生了變化,咱們可能要撤銷還在等待的前一個操做。嗯???這不正好與咱們上面所提到的需求很相似,你們能夠從尤大的文章尋找更好的方案。

經過上面兩個方向的探討,咱們發現兩種方案均可以免數據錯亂的狀況發生。兩種方案也不只在這種需求的狀況下可使用,一樣也能夠在避免用戶屢次點擊提交,屢次下載等需求狀況下調整使用。固然這算是一個優化點。文章中若有錯誤,請指正,謝謝!!!

相關文章
相關標籤/搜索