1. 程式人生 > 實用技巧 >Promise、Generator,Async/await

Promise、Generator,Async/await

我們知道JavaScript是單執行緒語言,如果沒有非同步程式設計非得卡死。
以前,非同步程式設計的方法有下面四種

  • 回撥函式
  • 事件監聽
  • 釋出/訂閱
  • Promise物件

現在據說非同步程式設計終極解決方案是——async/await

一、回撥函式

所謂回撥函式(callback),就是把任務分成兩步完成,第二步單獨寫在一個函式裡面,等到重新執行這個任務時,就直接呼叫這個函式。

例如Node.js中讀取檔案

fs.readFile('a,txt', (err,data) = >{
  if(err) throw err;
  console.log(data);
})

上面程式碼中readFile的第二個引數就是回撥函式,等到讀取完a.txt檔案時,這個函式才會執行。

二、Promise

使用回撥函式本身沒有問題,但有“回撥地獄”的問題。
假定我們有一個需求,讀取完A檔案之後讀取B檔案,再讀取C檔案,程式碼如下

fs.readFile(fileA,  (err, data) => {
  fs.readFile(fileB,  (err, data) => {
      fs.readFile(fileC, (err,data)=>{
        //do something
    })
  });
});

可見,三個回撥函式程式碼看來就夠嗆了,有時在實際業務中還不止巢狀這幾個,難以管理。

這時候Promise出現了!它不是新的功能,而是一種新的寫法,用來解決“回撥地獄”的問題。

我們再假定一個業務,分多個步驟完成,每個步驟都是非同步的,而且依賴於上一個步驟的結果,用setTimeout()來模擬非同步操作

/**
 * 傳入引數 n,表示這個函式執行的時間(毫秒)
 * 執行的結果是 n + 200,這個值將用於下一步驟
 */
function A(n) {
    return new Promise(resolve => {
        setTimeout(() => resolve(n + 200), n);
    });
}

function step1(n) {
    console.log(`step1 with ${n}`);
    return A(n);
}

function step2(n) {
    console.log(`step2 with ${n}`);
      return A(n);
}

function step3(n) {
    console.log(`step3 with ${n}`);
     return A(n);
}

上面程式碼中有4個函式,A()返回一個Promise物件,接收引數n,n秒後執行resolve(n+200)。step1、 step2、step3對應三個步驟

現在用Promise實現這三個步驟:

function doIt() {
    console.time('do it now')
    const time1 = 300;
    step1(time1)
          .then( time2 =>step2(time2))
          .then( time3 => step3(time3))
          .then( result => {
              console.log(`result is ${result}`)
           });
}

doIt();

輸出結果如下

step1 with 300
step2 with 500
step3 with 700
result is 900

result是step3()的引數700+200 = 900。

可見,Promise的寫法只是回撥函式的改進,用then()方法免去了巢狀,更為直觀。
但這樣寫絕不是最好的,程式碼變得十分冗餘,一堆的then。
所以,最優秀的解決方案是什麼呢?
開頭暴露了,就是async/await
講async前我們先講講協程Generator

三、協程

協程(coroutine),意思是多個執行緒相互協作,完成非同步任務。

它的執行流程如下

  • 協程A開始執行
  • 協程A執行到一半,暫停執行,執行的權利轉交給協程B。
  • 一段時間後B交還執行權
  • 協程A重得執行權,繼續執行

上面的協程A就是一個非同步任務,因為在執行過程中執行權被B搶了,被迫分成兩步完成。

讀取檔案的協程程式碼如下:

function task() {
  // 其他程式碼
   var f = yield readFile('a.txt')
   // 其他程式碼
}

task()函式就是一個協程,函式內部有個新單詞yield,yield中文意思為退讓,
顧名思義,它表示執行到此處,task協程該交出它的執行權了。也可以把yield命令理解為非同步兩個階段的分界線。

協程遇到yield命令就會暫停,把執行權交給其他協程,等到執行權返回繼續往後執行。最大的優點就是程式碼寫法和同步操作幾乎沒有差別,只是多了yield命令。

這也是非同步程式設計追求的,讓它更像同步程式設計

四、Generator函式

Generator是協程在ES6的實現,最大的特點就是可以交出函式的執行權,懂得退讓。

function* gen(x) {
    var y = yield x +2;
    return y;
  }
  
  var g = gen(1);
  console.log( g.next()) // { value: 3, done: false }
  console.log( g.next()) // { value: undefined, done: true }

上面程式碼中,函式多了*號,用來表示這是一個Generator函式,和普通函式不一樣,不同之處在於執行它不會返回結果,

返回的是指標物件g,這個指標g有個next方法,呼叫它會執行非同步任務的第一步。
物件中有兩個值,value和done,value 屬性是 yield 語句後面表示式的值,表示當前階段的值,done表示是否Generator函式是否執行完畢。

下面看看Generator函式如何執行一個真實的非同步任務

var fetch = require('node-fetch');

function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
  console.log(result.bio);
}

var g = gen();
var result = g.next();

result.value.then( data => return data.json)
                  .then (data => g.next(data))

上面程式碼中,首先執行Generator函式,得到物件g,呼叫next方法,此時
result ={ value: Promise { <pending> }, done: false }
因為fetch返回的是一個Promise物件,(即value是一個Promise物件)所以要用then才能呼叫下一個next方法。

雖然Generator將非同步操作表示得很簡潔,但是管理麻煩,何時執行第一階段,又何時執行第二階段?

是的,這時候到Async/await出現了!

五、Async/await

從回撥函式,到Promise物件,再到Generator函式,JavaScript非同步程式設計解決方案歷程可謂辛酸,終於到了Async/await。很多人認為它是非同步操作的最終解決方案(謝天謝地,這下不用再學新的解決方案了吧)

其實async函式就是Generator函式的語法糖,例如下面兩個程式碼:

var gen = function* (){
  var f1 = yield readFile('./a.txt');
  var f2 = yield readFile('./b.txt');
  console.log(f1.toString());
  console.log(f2.toString());
};
var asyncReadFile = async function (){
  var f1 = await  readFile('./a.txt');
  var f2 = await  readFile('./b.txt');
  console.log(f1.toString());
  console.log(f2.toString());
};

上面的為Generator函式讀取兩個檔案,下面為async/await讀取,比較可發現,兩個函式其實是一樣的,async不過是把Generator函式的*號換成async,yield換成await。

1.async函式用法

上面說了async不過是Generator函式的語法糖,那為什麼要取這個名字呢?自然是有理由的。
async是“非同步”,而await是async wait的簡寫,即非同步等待。所以應該很好理解async用於宣告一個function是非同步的,await用於等待一個非同步方法執行完成

下面來看一個例子理解async命令的作用

async function test() {
  return "async 有什麼用?";
}
const result = test();
console.log(result) 

輸出:
Promise { 'async 有什麼用?' }
可以看到,輸出的是一個Promise物件!
所以,async函式返回的是一個Promise物件,如果直接return 一個直接量,async會把這個直接量通過PromIse.resolve()封裝成Promise物件

注意點
一般來說,都認為await是在等待一個async函式完成,確切的說等待的是一個表示式,這個表示式的計算結果是Promise物件或者是其他值(沒有限定是什麼)

即await後面不僅可以接Promise,還可以接普通函式或者直接量。

同時,我們可以把async理解為一個運算子,用於組成表示式,表示式的結果取決於它等到的東西

  • 等到非Promise物件 表示式結果為它等到的東西
  • 等到Promise物件 await就會阻塞後面的程式碼,等待Promise物件resolve,取得resolve的值,作為表示式的結果

還是那個業務,分多個步驟完成,每個步驟依賴於上一個步驟的結果,用setTimeout模擬非同步操作。

/**
 * 傳入引數 n,表示這個函式執行的時間(毫秒)
 * 執行的結果是 n + 200,這個值將用於下一步驟
 */
function takeLongTime(n) {
    return new Promise(resolve => {
        setTimeout(() => resolve(n + 200), n);
    });
}

function step1(n) {
    console.log(`step1 with ${n}`);
    return takeLongTime(n);
}

function step2(n) {
    console.log(`step2 with ${n}`);
    return takeLongTime(n);
}

function step3(n) {
    console.log(`step3 with ${n}`);
    return takeLongTime(n);
}

async實現方法

async function doIt() {
    console.time("doIt");
    const time1 = 300;
    const time2 = await step1(time1);
    const time3 = await step2(time2);
    const result = await step3(time3);
    console.log(`result is ${result}`);
    console.timeEnd("doIt");
}

doIt();

輸出結果和上面用Promise實現是一樣的,但這個程式碼結構看起來清晰得多,幾乎跟同步寫法一樣。

2. async函式的優點

(1)內建執行器
Generator 函式的執行必須靠執行器,所以才有了 co 函式庫,而 async 函式自帶執行器。也就是說,async 函式的執行,與普通函式一模一樣,只要一行。

(2) 語義化更好
async 和 await,比起星號和 yield,語義更清楚了。async 是“非同步”的簡寫,而 await 可以認為是 async wait 的簡寫。所以應該很好理解 async 用於申明一個 function 是非同步的,而 await 用於等待一個非同步方法執行完成。

(3)更廣的適用性
yield 命令後面只能是 Thunk 函式或 Promise 物件,而 async 函式的 await 命令後面,可以跟 Promise 物件和原始型別的值(數值、字串和布林值,但這時等同於同步操作)。