vue中async-await的使用誤區

issaxite發表於2018-11-25

前言

曾經見過為了讓鉤子函式的非同步程式碼可以同步執行並且阻塞主執行緒直到鉤子全部邏輯處理完,而對鉤子函式使用async/await,就好像下面的程式碼:

// exp-01
export default {
  async created() {
    const timeKey = 'cost';
    console.time(timeKey);
    console.log('start created');

    this.list = await this.getList();

    console.log(this.list);
    console.log('end created');
    console.timeEnd(timeKey);
  },

  mounted() {
    const timeKey = 'cost';
    console.time(timeKey);
    console.log('start mounted');
    
    console.log(this.list.rows);

    console.log('end mounted');
    console.timeEnd(timeKey);
  },

  data() {
    return {
      list: []
    };
  },

  methods: {
    getList() {
      return new Promise((resolve) => {
        setTimeout(() => {
          return resolve({
            rows: [
              { name: 'isaac', position: 'coder' }
            ]
          });
        }, 3000);
      });
    }
  }
};
複製程式碼

exp-01的程式碼最後會輸出:

start created
start mounted
undefined
end mounted
mounted cost: 2.88623046875ms
{__ob__: Observer}
end created
created cost: 3171.545166015625ms
複製程式碼

很明顯沒有達到預期的效果,為什麼?

根據exp-01的輸出結果,可以看出程式碼的執行順序,首先是鉤子的執行順序:

created => mounted
複製程式碼

是的,鉤子的執行順序還是正常的沒有被打亂,證據就是:created鉤子中的同步程式碼是在mounted先執行的:

start created
start mounted
複製程式碼

再看看created鉤子內部的非同步程式碼:

this.list = await this.getList();
複製程式碼

可以看見this.list的列印結果

end mounted
mounted cost: 2.88623046875ms
// 這是created鉤子列印的this.list
{__ob__: Observer}
end created
複製程式碼

在mounted鉤子執行完畢之後才列印,言外之意是使用async/await的鉤子內部的非同步程式碼並沒有起到阻塞鉤子主執行緒的執行。這裡說的鉤子函式的主執行緒是指:

beforeCreate => created => beforeMount => mounted => ...
複製程式碼

會寫出以上程式碼的原因我估計有兩個:

  1. 對非同步程式碼返回的資料有強依賴,因此希望在mounted鉤子內容呼叫完成前,先執行完created的內容(包括非同步程式碼的回撥函式);
  2. 僅僅是希望鉤子函式的非同步程式碼可以按照編寫順序執行(exp-01是確實實現了的),但卻沒想到帶來了副作用;

正文

剖析一下

前言中針對程式碼的執行流程分析了一下,很明顯沒有如期望的順序執行,我們先來回顧一下期望的順序是什麼

// step 1
created() {
  // step 1.1
  let endTime;
  const startTime = Date.now();
  console.log(`start created: ${startTime}ms`);
  // step 1.2
  this.list = await this.getList();
  endTime = Date.now();
  console.log(this.list);
  console.log(`end created: ${endTime}ms, cost: ${endTime - startTime}ms`);
},
// step 2
mounted() {
  let endTime;
  const startTime = Date.now();
  console.log(`start mounted: ${startTime}ms`);
  console.log(this.list.rows);
  endTime = Date.now();
  console.log(`end mounted: ${endTime}ms, cost: ${endTime - startTime}ms`);
}

// step 1 => step 1.1 => step 1.2 => step 2 
複製程式碼

期望的列印結果是:

// step 1(created)
start created
// this.list
{__ob__: Observer}
end created
created cost: 3171.545166015625ms

// step 2(mounted)
start mounted
// this.list.rows
[{…}, __ob__: Observer]
end mounted
mounted cost: 2.88623046875ms

複製程式碼

對比實際的列印和期望的列印,就知道問題出在created鉤子內使用了await的非同步程式碼,並沒有達到我們期望的那種的“非同步程式碼同步執行”的效果,僅僅是一定程度上達到了這個效果。

下面來分析一下為什麼會出現這個非預期的結果!

在分析前,讓我們來回顧一下一些javascript的基礎知識!看看下面這段程式碼:

(function __main() {
  console.log('start');
  setTimeout(() => {
    console.log('console in setTimeout');
  }, 0);
  console.log('end');
})()

// output
start
end
console in setTimeout
複製程式碼

這個列印順序有沒有讓你想到什麼?!

 

任務佇列!

 

vue中async-await的使用誤區

我們都知道JavaScript的程式碼可以分成兩類:

同步程式碼非同步程式碼

同步程式碼會在主執行緒按照編寫順序執行;

非同步程式碼的觸發過程(注意是觸發,比如非同步請求的發起,就是在主執行緒同步觸發的)是同步的,但是非同步程式碼的實際處理邏輯(回撥函式)則會在非同步程式碼有響應時將處理邏輯程式碼推入任務佇列(也叫事件佇列),瀏覽器會在主執行緒(指當前執行環境的同步程式碼)程式碼執行完畢後以一定的週期檢測任務佇列,若有需要處理的任務,就會讓隊頭的任務出隊,推入主執行緒執行。

比如現在我們發起一個非同步請求:

// exp-02
console.log('start');
axios.get('http://xxx.com/getList')
  .then((resp) => {
    console.log('handle response');
  })
  .catch((error) => {
    console.error(error);
  });
console.log('end');
複製程式碼

在主執行緒中,大概首先會發生如下過程:

// exp-03
// step 1
console.log('start');

// step 2
axios.get('http://xxx.com/getList');  // 此時回撥函式(即then內部的邏輯)還沒有被呼叫

// step 3
console.log('end');
複製程式碼

在看看瀏覽器此時在幹什麼!

此時事件輪詢(Event Loop)登場,其實並非此時才登場,而是一直都在!

“事件輪詢”這個機制會以一定的週期檢測任務佇列有沒有可執行的任務(所謂任務其實就是callback),有即出隊執行。

step 2的請求有響應了,非同步請求的回撥函式就會被新增到任務佇列(Task Queue)或者 稱為 事件佇列(Event Queue),然後等到事件輪詢的下一次檢測任務佇列,佇列裡面任務就會依次出隊,進入主執行緒執行:即執行下面的程式碼:

// 假定沒有出錯的話
((resp) => {
  console.log('handle response');
})()
複製程式碼

到此,簡短科普了任務佇列的機制,聯想exp-01的程式碼,大概知道出現非預期結果的原因了吧!

created鉤子中的await函式,雖然是在一定程度上是同步的,但是他還是被掛起了,實際的處理邏輯(this.list = resp.xxx)則在響應完成後才被新增進任務佇列,並且在主執行緒的同步程式碼執行完畢後執行。 下面是將延時時間設為0後的列印:

start created
start mounted
undefined
end mounted
mounted cost: 2.88623046875ms
{__ob__: Observer}
end created
created cost: 9.76611328125ms
複製程式碼

這側面說明了await函式確實被被掛起,回撥被新增到任務佇列,在主執行緒程式碼執行完畢後等待執行。  

然後是為什麼說exp-01的程式碼是一定程度的同步呢?!

同步執行的另一個意思是不是就是:阻塞當前執行緒的繼續執行直到當前邏輯執行完畢~

看看exp-01的列印:

{__ob__: Observer}
end created
created cost: 3171.545166015625ms
複製程式碼

end created這句列印,是主執行緒的程式碼,如果是一般的非同步請求的話,這句列印應該是在{__ob__: Observer}這句列印之前的yo,至於為什麼會這樣,這裡就不多解析,自行google!

另外,這裡來個小插曲,你應該注意到,我一直強調,回撥函式被新增進任務佇列的時機是在響應完成之後,沒錯確實如此的!

但在不清除這個機制前,你大概會有兩種猜想:

  1. 在觸發非同步程式碼的時,處理邏輯就會被新增進任務佇列;
  2. 上面說到的,在非同步程式碼響應完成後,處理邏輯才會被新增進任務佇列;

其實大可推斷一下

佇列的資料結構特徵是:先進先出(First in First out)

此時假如主執行緒中有兩個非同步請求如下:

// exp-04
syncRequest01(callback01);
syncRequest02(callback02);
複製程式碼

假設處理機制是第一點描述那樣,那麼callback01就會先被新增進任務佇列,然後是callback02。

然後,我們再假設syncRequest01的響應時間是10s,syncRequest02的響應時間是5s。

到這裡,有沒有察覺到違和感!

非同步請求的實際表現是什麼?是誰快誰的回撥先被執行,對吧!那麼實際表現就是callback02會先於callback01執行!

那麼基於這個事實,再看看上面的假設(callback01會執行)~

ok!插曲完畢!

解法

首先讓我回顧一下目的,路由元件對非同步請求返回的資料有強依賴,因此希望阻塞元件的渲染流程,待到非同步請求響應完畢之後再執行。

這就是我們需要做的事情,需要強調的一點是:我們對資料有強依賴,言外之意就是資料沒有按預期返回,就會導致之後的邏輯出現不可避免的異常。

接下來,我們就需要探討一下解決方案!

元件內路由守衛瞭解一下!?

beforeRouteEnter

beforeRouteUpdate (2.2 新增)

beforeRouteLeave

這裡需要用到的路由守衛是:beforeRouterEnter, 先看程式碼:

// exp-05
const storage = {};
export default {
  beforeRouteEnter(to, from, next) {
    showLoading();
    getList()
      .then((resp) => {
        hideLoading();
        storage.list = resp.data;
        next();
      })
      .catch((error) => {
        hideLoading();
        // handle error
      });
  },

  mounted() {
    let endTime;
    const startTime = Date.now();
    console.log(`start mounted: ${startTime}ms`);
    console.log(storage.list.rows);
    endTime = Date.now();
    console.log(`end mounted: ${endTime}ms, cost: ${endTime - startTime}ms`);
  },
};
複製程式碼

路由守衛beforeRouterEnter,觸發這個鉤子後,主執行緒都會阻塞,頁面會一直保持假死狀態,直到在呼叫beforeRouterEnter的回撥函式next,才會跳轉路由進行新路由元件的渲染。

看起這個解決方案相當適合上面我們提出的需求,在呼叫next前,就可以去拉取資料!

但是如剛剛說到的,頁面在一直假死,加入資料獲取花費時間過長就難免變得很難看,使用者體驗未免太差

為此,在exp-05中我在請完成前後分別呼叫了showLoading()hideLoading()以便頁面keep-alive

這個處理假死的loading有沒有讓你想到寫什麼,沒錯就是下面這個github跳轉頁面是頂部的小藍條

vue中async-await的使用誤區

想想就有點cool,當然還有很多的實現方式提升使用者體驗,比如作為body子元素的全屏loading,或者button-loading等等……

當然,我們知道阻塞主執行緒怎麼都是阻塞了,loading只是一種自欺欺人式的優化(此時這個成語可不是什麼貶義的詞語)!

因此,不是對資料有非常強的依賴,都應在路由的鉤子進行資料抓取,這樣就可以讓使用者“更快”地跳轉到目的頁。為避免頁面對資料依賴丟擲的異常(大概就是 undefined of xxx),我們可以對初始資料進行一些預設,比如exp-01中對this.list.rows的依賴,我們可以預設this.list

list: {
  rows: []
}
複製程式碼

這樣就不會丟擲異常,待到非同步請求完成,基於vue的update機制二次渲染我們的預期資料~

修改後的宣告

在修改前,原有的解決方式是:

// exp-05
export default {
  beforeRouteEnter(to, from, next) {
    this.showLoading();
    this.getList()
      .then((resp) => {
        this.hideLoading();
        this.list = resp.data;
        next();
      })
      .catch((error) => {
        this.hideLoading();
        // handle error
      });
  },

  mounted() {
    let endTime;
    const startTime = Date.now();
    console.log(`start mounted: ${startTime}ms`);
    console.log(this.list.rows);
    endTime = Date.now();
    console.log(`end mounted: ${endTime}ms, cost: ${endTime - startTime}ms`);
  },
};
複製程式碼

經過的評論中的大佬們教做人後,針對以下兩點做了修改:

  1. beforeRouteEnter的函式體中是訪問不到當前元件的上下文的,需要在回撥參引數next(這是個函式引用)中,使用next這個函式的回撥引數vm(next(vm => {}))中的vm才能訪問到當前元件的上下文;
  2. 雖然vm可以訪問到元件的上下文,但是有個問題,就是你通過vm來做get/set,這個get/set動作是在beforeCreatecreatedbeforeMountmounted這些鉤子執行之後~這樣的話,beforeRouterEnter的阻塞作用基本就廢掉了!

小結

對於exp-01的寫法,也不能說他是錯誤或不好的寫法,凡事都要看我們是出於什麼目的,如果僅僅是為了保證多個非同步函式的執行順序,exp-01的寫法沒有任何錯誤,因此async/await不能用在路由鉤子上什麼的並不存在!

it just a tool!

歡迎提出不同得見解,提bug和評論,我們issue相見

相關文章