1. 程式人生 > >promise,async,await

promise,async,await

Promise 是非同步程式設計的一種解決方案:從語法上講,promise是一個物件,從它可以獲取非同步操作的訊息;從本意上講,它是承諾,承諾它過一段時間會給你一個結果。promise有三種狀態:pending(等待態),fulfiled(成功態),rejected(失敗態);狀態一旦改變,就不會再變。創造promise例項後,它會立即執行。

promise是用來解決兩個問題的:

  • 回撥地獄,程式碼難以維護, 常常第一個的函式的輸出是第二個函式的輸入這種現象
  • promise可以支援多個併發的請求,獲取併發請求中的資料
  • 這個promise可以解決非同步的問題,本身不能說promise是非同步的

我們可以通俗的說promise是一個非同步呼叫的直接返回的結果,一個非同步呼叫直接返回一個promise物件,通過這個物件可以得到一定時間之後這個非同步呼叫的結果。

Promise是一個建構函式,自己身上有all、reject、resolve這幾個眼熟的方法,原型上有then、catch等同樣很眼熟的方法。

那就new一個

 
  1. let p = new Promise((resolve, reject) => {

  2. //做一些非同步操作

  3. setTimeout(() => {

  4. console.log('執行完成');

  5. resolve('我是成功!!');

  6. }, 2000);

  7. });

Promise的建構函式接收一個引數:函式,並且這個函式需要傳入兩個引數:

  • resolve :非同步操作執行成功後的回撥函式
  • reject:非同步操作執行失敗後的回撥函式

resovle把promice物件的狀態變成成功狀態,然後還可以傳遞引數給成功的回撥函式,通過then呼叫成功的回撥函式,then的引數就是成功的回撥函式,這個回撥函式的引數就是resovle傳遞進去的

reject把promice物件的狀態變成失敗狀態,然後還可以傳遞引數給失敗的回撥函式,通過catch呼叫失敗的回撥函式,catch的引數就是失敗的回撥函式,這個回撥函式的引數就是reject傳遞進去的

then 鏈式操作的用法  

所以,從表面上看,Promise只是能夠簡化層層回撥的寫法,而實質上,Promise的精髓是“狀態”,用維護狀態、傳遞狀態的方式來使得回撥函式能夠及時呼叫,它比傳遞callback函式要簡單、靈活的多。所以使用Promise的正確場景是這樣的:

 
  1. p.then((data) => {

  2. console.log(data);

  3. })

  4. .then((data) => {

  5. console.log(data);

  6. })

  7. .then((data) => {

  8. console.log(data);

  9. });

reject的用法 :

把Promise的狀態置為rejected,這樣我們在then中就能捕捉到,然後執行“失敗”情況的回撥。看下面的程式碼。

 
  1. let p = new Promise((resolve, reject) => {

  2. //做一些非同步操作

  3. setTimeout(function(){

  4. var num = Math.ceil(Math.random()*10); //生成1-10的隨機數

  5. if(num<=5){

  6. resolve(num);

  7. }

  8. else{

  9. reject('數字太大了');

  10. }

  11. }, 2000);

  12. });

  13. p.then((data) => {

  14. console.log('resolved',data);

  15. },(err) => {

  16. console.log('rejected',err);

  17. }

  18. );

then中傳了兩個引數,then方法可以接受兩個引數,第一個對應resolve的回撥,第二個對應reject的回撥。所以我們能夠分別拿到他們傳過來的資料。多次執行這段程式碼,你會隨機得到下面兩種結果:

或者

catch的用法

我們知道Promise物件除了then方法,還有一個catch方法,它是做什麼用的呢?其實它和then的第二個引數一樣,用來指定reject的回撥。用法是這樣:

 

 
  1. p.then((data) => {

  2. console.log('resolved',data);

  3. }).catch((err) => {

  4. console.log('rejected',err);

  5. });

效果和寫在then的第二個引數裡面一樣。不過它還有另外一個作用:在執行resolve的回撥(也就是上面then中的第一個引數)時,如果丟擲異常了(程式碼出錯了),那麼並不會報錯卡死js,而是會進到這個catch方法中。請看下面的程式碼:

 
  1. p.then((data) => {

  2. console.log('resolved',data);

  3. console.log(somedata); //此處的somedata未定義

  4. })

  5. .catch((err) => {

  6. console.log('rejected',err);

  7. });

在resolve的回撥中,我們console.log(somedata);而somedata這個變數是沒有被定義的。如果我們不用Promise,程式碼執行到這裡就直接在控制檯報錯了,不往下運行了。但是在這裡,會得到這樣的結果:

也就是說進到catch方法裡面去了,而且把錯誤原因傳到了reason引數中。即便是有錯誤的程式碼也不會報錯了,這與我們的try/catch語句有相同的功能

all的用法:封裝多個promise例項,並返回一個新的promise物件,之後當封裝的promise陣列全部resolve,則該新promise正常返回,可以呼叫then,返回的是一個數組(多個promise的返回)。如果封裝的promise陣列中有一個reject,那麼新promsie失敗返回,catch呼叫返回結果。不論新promise是正常還是失敗,所有的封裝的每一個promise都會執行不暫停,race也一樣。

let p1 = new Promise((resolve, reject) => resolve(42))
let p2 = new Promise((resolve, reject) => reject(43))
let p3 = new Promise((resolve, reject) => resolve(44))

let p4 = Promise.all([p1,p2, p3])
p3.then(v=>console.log('p3 then: ' + v)) // p1 then: 42
p1.then(v=>console.log('p1 then: ' + v)) // p1 then: 42
p2.then(v=>console.log('p2 then: ' + v)) // p1 then: 42
p2.catch(v=>console.log('p2 catch: ' + v)) // p1 then: 42
p4.then(v=>console.log('p4 then: ' + Array.isArray(v)+v[0]+v[1])) // 不執行
p4.catch(v=>console.log('catch: ' + v)) // catch: 43 p2被拒絕,p4的拒絕處理函式立即呼叫,不會等p1和p3結束執行。但p1和p2仍然會執行。

race的用法:和all的區別就是隻要有一個返回那麼新的promise就返回。

let r2 = new Promise((resolve, reject) =>{
    resolve('r42')
})
let r1 = Promise.resolve('r41')
let r3 = new Promise((resolve, reject) =>{
    reject('rejcet43')
})
let r4 = Promise.race([r1, r2, r3])
r4.then(v=>console.log('r4: ' + v)) // r4: r41
r3.catch(v=>console.log('r3: ' + v)) //
r1.then(v=>console.log('r1: ' + v)) //
r2.then(v=>console.log('r2: ' + v)) //

非同步操作是 JavaScript 程式設計的麻煩事,麻煩到一直有人提出各種各樣的方案,試圖解決這個問題。

從最早的回撥函式,到 Promise 物件,再到 Generator 函式,每次都有所改進,但又讓人覺得不徹底。它們都有額外的複雜性,都需要理解抽象的底層執行機制。

非同步I/O不就是讀取一個檔案嗎,幹嘛要搞得這麼複雜?非同步程式設計的最高境界,就是根本不用關心它是不是非同步。

async 函式就是隧道盡頭的亮光,很多人認為它是非同步操作的終極解決方案。

二、async 函式是什麼?

一句話,async 函式就是 Generator 函式的語法糖。

有一個 Generator 函式,依次讀取兩個檔案。


var fs = require('fs');

var readFile = function (fileName){
  return new Promise(function (resolve, reject){
    fs.readFile(fileName, function(error, data){
      if (error) reject(error);
      resolve(data);
    });
  });
};

var gen = function* (){
  var f1 = yield readFile('/etc/fstab');
  var f2 = yield readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

寫成 async 函式,就是下面這樣。


var asyncReadFile = async function (){
  var f1 = await readFile('/etc/fstab');
  var f2 = await readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

一比較就會發現,async 函式就是將 Generator 函式的星號(*)替換成 async,將 yield 替換成 await,僅此而已。

三、async 函式的優點

async 函式對 Generator 函式的改進,體現在以下三點。

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


var result = asyncReadFile();

(2)更好的語義。 async 和 await,比起星號和 yield,語義更清楚了。async 表示函式裡有非同步操作,await 表示緊跟在後面的表示式需要等待結果。

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

四、async 函式的實現

async 函式的實現,就是將 Generator 函式和自動執行器,包裝在一個函式裡。


async function fn(args){
  // ...
}

// 等同於

function fn(args){ 
  return spawn(function*() {
    // ...
  }); 
}

所有的 async 函式都可以寫成上面的第二種形式,其中的 spawn 函式就是自動執行器。

下面給出 spawn 函式的實現,基本就是前文自動執行器的翻版。


function spawn(genF) {
  return new Promise(function(resolve, reject) {
    var gen = genF();
    function step(nextF) {
      try {
        var next = nextF();
      } catch(e) {
        return reject(e); 
      }
      if(next.done) {
        return resolve(next.value);
      } 
      Promise.resolve(next.value).then(function(v) {
        step(function() { return gen.next(v); });      
      }, function(e) {
        step(function() { return gen.throw(e); });
      });
    }
    step(function() { return gen.next(undefined); });
  });
}

async 起什麼作用

這個問題的關鍵在於,async 函式是怎麼處理它的返回值的!

我們當然希望它能直接通過 return 語句返回我們想要的值,但是如果真是這樣,似乎就沒 await 什麼事了。所以,寫段程式碼來試試,看它到底會返回什麼:

async function testAsync() {
    return "hello async";
}

const result = testAsync();
console.log(result);

看到輸出就恍然大悟了——輸出的是一個 Promise 物件。

c:\var\test> node --harmony_async_await .
Promise { 'hello async' }

所以,async 函式返回的是一個 Promise 物件。從文件中也可以得到這個資訊。async 函式(包含函式語句、函式表示式、Lambda表示式)會返回一個 Promise 物件,如果在函式中 return 一個直接量,async 會把這個直接量通過 Promise.resolve() 封裝成 Promise 物件。

async 函式返回的是一個 Promise 物件,所以在最外層不能用 await 獲取其返回值的情況下,我們當然應該用原來的方式:then() 鏈來處理這個 Promise 物件,就像這樣

testAsync().then(v => {
    console.log(v);    // 輸出 hello async
});

現在回過頭來想下,如果 async 函式沒有返回值,又該如何?很容易想到,它會返回 Promise.resolve(undefined)

聯想一下 Promise 的特點——無等待,所以在沒有 await 的情況下執行 async 函式,它會立即執行,返回一個 Promise 物件,並且,絕不會阻塞後面的語句。這和普通返回 Promise 物件的函式並無二致。

那麼下一個關鍵點就在於 await 關鍵字了。

await 到底在等啥

一般來說,都認為 await 是在等待一個 async 函式完成。不過按語法說明,await 等待的是一個表示式,這個表示式的計算結果是 Promise 物件或者其它值(換句話說,就是沒有特殊限定)。

因為 async 函式返回一個 Promise 物件,所以 await 可以用於等待一個 async 函式的返回值——這也可以說是 await 在等 async 函式,但要清楚,它等的實際是一個返回值。注意到 await 不僅僅用於等 Promise 物件,它可以等任意表達式的結果,所以,await 後面實際是可以接普通函式呼叫或者直接量的。所以下面這個示例完全可以正確執行

function getSomething() {
    return "something";
}

async function testAsync() {
    return Promise.resolve("hello async");
}

async function test() {
    const v1 = await getSomething();
    const v2 = await testAsync();
    console.log(v1, v2);
}

test();

await 等到了要等的,然後呢

await 等到了它要等的東西,一個 Promise 物件,或者其它值,然後呢?我不得不先說,await 是個運算子,用於組成表示式,await 表示式的運算結果取決於它等的東西。

如果它等到的不是一個 Promise 物件,那 await 表示式的運算結果就是它等到的東西。

如果它等到的是一個 Promise 物件,await 就忙起來了,它會阻塞後面的程式碼,等著 Promise 物件 resolve,然後得到 resolve 的值,作為 await 表示式的運算結果。

看到上面的阻塞一詞,心慌了吧……放心,這就是 await 必須用在 async 函式中的原因。async 函式呼叫不會造成阻塞,它內部所有的阻塞都被封裝在一個 Promise 物件中非同步執行。

async/await 幫我們幹了啥

作個簡單的比較

上面已經說明了 async 會將其後的函式(函式表示式或 Lambda)的返回值封裝成一個 Promise 物件,而 await 會等待這個 Promise 完成,並將其 resolve 的結果返回出來。

現在舉例,用 setTimeout 模擬耗時的非同步操作,先來看看不用 async/await 會怎麼寫

function takeLongTime() {
    return new Promise(resolve => {
        setTimeout(() => resolve("long_time_value"), 1000);
    });
}

takeLongTime().then(v => {
    console.log("got", v);
});

如果改用 async/await 呢,會是這樣

function takeLongTime() {
    return new Promise(resolve => {
        setTimeout(() => resolve("long_time_value"), 1000);
    });
}

async function test() {
    const v = await takeLongTime();
    console.log(v);
}

test();

眼尖的同學已經發現 takeLongTime() 沒有申明為 async。實際上,takeLongTime() 本身就是返回的 Promise 物件,加不加 async 結果都一樣,如果沒明白,請回過頭再去看看上面的“async 起什麼作用”。

又一個疑問產生了,這兩段程式碼,兩種方式對非同步呼叫的處理(實際就是對 Promise 物件的處理)差別並不明顯,甚至使用 async/await 還需要多寫一些程式碼,那它的優勢到底在哪?

async/await 的優勢在於處理 then 鏈

單一的 Promise 鏈並不能發現 async/await 的優勢,但是,如果需要處理由多個 Promise 組成的 then 鏈的時候,優勢就能體現出來了(很有意思,Promise 通過 then 鏈來解決多層回撥的問題,現在又用 async/await 來進一步優化它)。

假設一個業務,分多個步驟完成,每個步驟都是非同步的,而且依賴於上一個步驟的結果。我們仍然用 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);
}

現在用 Promise 方式來實現這三個步驟的處理

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

doIt();

// c:\var\test>node --harmony_async_await .
// step1 with 300
// step2 with 500
// step3 with 700
// result is 900
// doIt: 1507.251ms

輸出結果 result 是 step3() 的引數 700 + 200 = 900doIt() 順序執行了三個步驟,一共用了 300 + 500 + 700 = 1500 毫秒,和 console.time()/console.timeEnd() 計算的結果一致。

如果用 async/await 來實現呢,會是這樣

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 實現是一樣的,但是這個程式碼看起來是不是清晰得多,幾乎跟同步程式碼一樣

還有更酷的

現在把業務要求改一下,仍然是三個步驟,但每一個步驟都需要之前每個步驟的結果。

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

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

function step3(k, m, n) {
    console.log(`step3 with ${k}, ${m} and ${n}`);
    return takeLongTime(k + m + n);
}

這回先用 async/await 來寫:

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

doIt();

// c:\var\test>node --harmony_async_await .
// step1 with 300
// step2 with 800 = 300 + 500
// step3 with 1800 = 300 + 500 + 1000
// result is 2000
// doIt: 2907.387ms

除了覺得執行時間變長了之外,似乎和之前的示例沒啥區別啊!別急,認真想想如果把它寫成 Promise 方式實現會是什麼樣子?

function doIt() {
    console.time("doIt");
    const time1 = 300;
    step1(time1)
        .then(time2 => {
            return step2(time1, time2)
                .then(time3 => [time1, time2, time3]);
        })
        .then(times => {
            const [time1, time2, time3] = times;
            return step3(time1, time2, time3);
        })
        .then(result => {
            console.log(`result is ${result}`);
            console.timeEnd("doIt");
        });
}

doIt();

六、注意點

await 命令後面的 Promise 物件,執行結果可能是 rejected,所以最好把 await 命令放在 try...catch 程式碼塊中。



async function myFunction() {
  try {
    await somethingThatReturnsAPromise();
  } catch (err) {
    console.log(err);
  }
}

// 另一種寫法

async function myFunction() {
  await somethingThatReturnsAPromise().catch(function (err){
    console.log(err);
  });
}

await 命令只能用在 async 函式之中,如果用在普通函式,就會報錯。



async function dbFuc(db) {
  let docs = [{}, {}, {}];

  // 報錯
  docs.forEach(function (doc) {
    await db.post(doc);
  });
}

上面程式碼會報錯,因為 await 用在普通函式之中了。但是,如果將 forEach 方法的引數改成 async 函式,也有問題。



async function dbFuc(db) {
  let docs = [{}, {}, {}];

  // 可能得到錯誤結果
  docs.forEach(async function (doc) {
    await db.post(doc);
  });
}

上面程式碼可能不會正常工作,原因是這時三個 db.post 操作將是併發執行,也就是同時執行,而不是繼發執行。正確的寫法是採用 for 迴圈。



async function dbFuc(db) {
  let docs = [{}, {}, {}];

  for (let doc of docs) {
    await db.post(doc);
  }
}

如果確實希望多個請求併發執行,可以使用 Promise.all 方法。



async function dbFuc(db) {
  let docs = [{}, {}, {}];
  let promises = docs.map((doc) => db.post(doc));

  let results = await Promise.all(promises);
  console.log(results);
}

// 或者使用下面的寫法

async function dbFuc(db) {
  let docs = [{}, {}, {}];
  let promises = docs.map((doc) => db.post(doc));

  let results = [];
  for (let promise of promises) {
    results.push(await promise);
  }
  console.log(results);
}