JavaScript 非同步進化史

穿越過來的鍵盤手發表於2016-08-07

前言

JS 中最基礎的非同步呼叫方式是 callback,它將回撥函式 callback 傳給非同步 API,由瀏覽器或 Node 在非同步完成後,通知 JS 引擎呼叫 callback。對於簡單的非同步操作,用 callback 實現,是夠用的。但隨著負責互動頁面和 Node 出現,callback 方案的弊端開始浮現出來。 Promise 規範孕育而生,並被納入 ES6 的規範中。後來 ES7 又在 Promise 的基礎上將 async 函式納入標準。此為 JavaScript 非同步進化史。

同步與非同步

通常,程式碼是由上往下依次執行的。如果有多個任務,就必需排隊,前一個任務完成,後一個任務才會執行。這種執行模式稱之為:同步(synchronous)。新手容易把計算機用語中的同步,和日常用語中的同步弄混淆。如,“把檔案同步到雲端”中的同步,指的是“使…保持一致”。而在計算機中,同步指的是任務從上往下依次執行的模式。比如:

A();
B();
C();

在這段程式碼中,A、B、C是三個不同的函式,每個函式都是一個不相關的任務。在同步模式,計算機會先執行 A 任務,再執行 B 任務,最後執行 C 任務。在大部分情況,同步模式都沒問題。但是如果 B 任務是一個耗時很長的網路請求,而 C 任務恰好是展現新頁面,就會導致網頁卡頓。

更好解決方案是,將 B 任務分成兩個部分。一部分立即執行網路請求的任務,另一部分在請求回來後的執行任務。這種一部分立即執行,另一部分在未來執行的模式稱為非同步。

A();
// 在現在傳送請求 
ajax('url1',function B() {
  // 在未來某個時刻執行
})
C();
// 執行順序 A => C => B

實際上,JS 引擎並沒有直接處理網路請求的任務,它只是呼叫了瀏覽器的網路請求介面,由瀏覽器傳送網路請求並監聽返回的資料。JavaScript 非同步能力的本質是瀏覽器或 Node 的多執行緒能力。

callback

未來執行的函式通常也叫 callback。使用 callback 的非同步模式,解決了阻塞的問題,但是也帶來了一些其他問題。在最開始,我們的函式是從上往下書寫的,也是從上往下執行的,這種“線性”模式,非常符合我們的思維習慣,但是現在卻被 callback 打斷了!在上面一段程式碼中,現在它跳過 B 任務先執行了 C任務!這種非同步“非線性”的程式碼會比同步“線性”的程式碼,更難閱讀,因此也更容易滋生 BUG。

試著判斷下面這段程式碼的執行順序,你會對“非線性”程式碼比“線性”程式碼更難以閱讀,體會更深。

A();

ajax('url1', function(){
    B();

    ajax('url2', function(){
        C();
    }
    D();

});
E();
// A => E => B => D => C

這段程式碼中,從上往下執行的順序被 Callback 打亂了。我們的閱讀程式碼視線是A => B => C => D => E,但是執行順序卻是A => E => B => D => C,這就是非線性程式碼帶來的糟糕之處。

通過將ajax後面執行的任務提前,可以更容易看懂程式碼的執行順序。雖然程式碼因為巢狀看起來不美觀,但現在的執行順序卻是從上到下的“線性”方式。這種技巧在寫多重巢狀的程式碼時,是非常有用的。

A();
E();

ajax('url1', function(){
    B();
    D();

    ajax('url2', function(){
        C();
    }

});
// A => E => B => D => C

上一段程式碼只有處理了成功回撥,並沒處理異常回撥。接下來,把異常處理回撥加上,再來討論程式碼“線性”執行的問題。

A();

ajax('url1', function(){
    B();

    ajax('url2', function(){
        C();
    },function(){
        D();
    });

},function(){
    E();

});

加上異常處理回撥後,url1的成功回撥函式 B 和異常回撥函式 E,被分開了。這種“非線性”的情況又出現了。

在 node 中,為了解決的異常回撥導致的“非線性”的問題,制定了錯誤優先的策略。node 中 callback 的第一個引數,專門用於判斷是否發生異常。

A();

get('url1', function(error){
    if(error){
        E();
    }else {
        B();

        get('url2', function(error){
            if(error){
                D();
            }else{
                C();
            }
        });
    }
});

到此,callback 引起的“非線性”問題基本得到解決。遺憾的是,使用 callback 巢狀,一層層if else和回撥函式,一旦巢狀層數多起來,閱讀起來不是很方便。此外,callback 一旦出現異常,只能在當前回撥函式內部處理異常。

promise

在 JavaScript 的非同步進化史中,湧現出一系列解決 callback 弊端的庫,而 Promise 成為了最終的勝者,併成功地被引入了 ES6 中。它將提供了一個更好的“線性”書寫方式,並解決了非同步異常只能在當前回撥中被捕獲的問題。

Promise 就像一箇中介,它承諾會將一個可信任的非同步結果返回。首先 Promise 和非同步介面簽訂一個協議,成功時,呼叫resolve函式通知 Promise,異常時,呼叫reject通知 Promise。另一方面 Promise 和 callback 也簽訂一個協議,由 Promise 在將來返回可信任的值給thencatch中註冊的 callback。

// 建立一個 Promise 例項(非同步介面和 Promise 簽訂協議)
var promise = new Promise(function (resolve,reject) {
  ajax('url',resolve,reject);
});

// 呼叫例項的 then catch 方法 (成功回撥、異常回撥與 Promise 簽訂協議)
promise.then(function(value) {
  // success
}).catch(function (error) {
  // error
})

Promise 是個非常不錯的中介,它只返回可信的資訊給 callback。它對第三方非同步庫的結果進行了一些加工,保證了 callback 一定會被非同步呼叫,且只會被呼叫一次。

var promise1 = new Promise(function (resolve) {
  // 可能由於某些原因導致同步呼叫
  resolve('B');
});
// promise依舊會非同步執行
promise1.then(function(value){
    console.log(value)
});
console.log('A');
// A B (先 A 後 B)

var promise2 = new Promise(function (resolve) {
  // 成功回撥被通知了2次
  setTimeout(function(){
    resolve();
  },0)
});
// promise只會呼叫一次
promise2.then(function(){
    console.log('A')
});
// A (只有一個)

var promise3 = new Promise(function (resolve,reject) {
  // 成功回撥先被通知,又通知了失敗回撥
  setTimeout(function(){
    resolve();
    reject();
  },0)

});
// promise只會呼叫成功回撥
promise3.then(function(){
    console.log('A')
}).catch(function(){
    console.log('B')
});
// A(只有A)

介紹完 Promise 的特性後,來看看它如何利用鏈式呼叫,解決非同步程式碼可讀性的問題的。

var fetch = function(url){
    // 返回一個新的 Promise 例項
    return new Promise(function (resolve,reject) {
        ajax(url,resolve,reject);
    });
}

A();
fetch('url1').then(function(){
    B();
    // 返回一個新的 Promise 例項
    return fetch('url2');
}).catch(function(){
    // 異常的時候也可以返回一個新的 Promise 例項
    return fetch('url2');
    // 使用鏈式寫法呼叫這個新的 Promise 例項的 then 方法    
}).then(function() {
    C();
    // 繼續返回一個新的 Promise 例項...
})
// A B C ...

如此反覆,不斷返回一個 Promise 物件,再採用鏈式呼叫的方式不斷地呼叫。使 Promise 擺脫了 callback 層層巢狀的問題和非同步程式碼“非線性”執行的問題。

Promise 解決的另外一個難點是 callback 只能捕獲當前錯誤異常。Promise 和 callback 不同,每個 callback 只能知道自己的報錯情況,但 Promise 代理著所有的 callback,所有 callback 的報錯,都可以由 Promise 統一處理。所以,可以通過catch來捕獲之前未捕獲的異常。

Promise 解決了 callback 的非同步呼叫問題,但 Promise 並沒有擺脫 callback,它只是將 callback 放到一個可以信任的中間機構,這個中間機構去連結我們的程式碼和非同步介面。

非同步(async)函式

非同步(async)函式是 ES7 的一個新的特性,它結合了 Promise,讓我們擺脫 callback 的束縛,直接用類同步的“線性”方式,寫非同步函式。

宣告非同步函式,只需在普通函式前新增一個關鍵字 async 即可,如async function main(){} 。在非同步函式中,可以使用await關鍵字,表示等待後面表示式的執行結果,一般後面的表示式是 Promise 例項。

async function main{
    // timer 是在上一個例子中定義的
    var value = await timer(100);
    console.log(value); // done (100ms 後返回 done)
}

main();

非同步函式和普通函式一樣呼叫 main() 。呼叫後,會立即執行非同步函式中的第一行程式碼 var value = await timer(100) 。等到非同步執行完成後,才會執行下一行程式碼。

除此之外,非同步函式和其他函式基本類似,它使用try...catch來捕捉異常。也可以傳入引數。但不要在非同步函式中使用return來返回值。

var  timer = new Promise(function create(resolve,reject) {
  if(typeof delay !== 'number'){
    reject(new Error('type error'));
  }
  setTimeout(resolve,delay,'done');
});

async function main(delay){
  try{
    var value1 = await timer(delay);
    var value2 = await timer('');
    var value3 = await timer(delay);
  }catch(err){
    console.error(err);
      // Error: type error
      //   at create (<anonymous>:5:14)
      //   at timer (<anonymous>:3:10)
      //   at A (<anonymous>:12:10)
  }
}
main(0);

非同步函式也可以被當作值,傳入普通函式和非同步函式中執行。但是在非同步函式中,使用非同步函式時要注意,如果不使用await,非同步函式會被同步執行。

async function main(delay){
    var value1 = await timer(delay);
    console.log('A')
}

async function doAsync(main){
  main(0);
  console.log('B')
}

doAsync(main);
// B A

這個時候列印出來的值是 B A。說明 doAsync 函式並沒有等待 main 的非同步執行完畢就執行了 console。如果要讓 console 在main 的非同步執行完畢後才執行,我們需要在main前新增關鍵字await

async function main(delay){
    var value1 = await timer(delay);
    console.log('A')
}

async function doAsync(main){
    await main(0);
    console.log('B')
}

doAsync(main);
// A B

由於非同步函式採用類同步的書寫方法,所以在處理多個併發請求,新手可能會像下面一樣書寫。這樣會導致url2的請求必需等到url1的請求回來後才會傳送。

var fetch = function (url) {
  return new Promise(function (resolve,reject) {
    ajax(url,resolve,reject);
  });
}

async function main(){
  try{
    var value1 = await fetch('url1');
    var value2 = await fetch('url2');
    conosle.log(value1,value2);
  }catch(err){
    console.error(err)
  }
}

main();

使用Promise.all的方法來解決這個問題。Promise.all用於將多個Promise例項,包裝成一個新的 Promis e例項,當所有的 Promise 成功後才會觸發Promise.allresolve函式,當有一個失敗,則立即呼叫Promise.allreject函式。

var fetch = function (url) {
  return new Promise(function (resolve,reject) {
    ajax(url,resolve,reject);
  });
}

async function main(){
  try{
    var arrValue = await Promise.all[fetch('url1'),fetch('url2')];
    conosle.log(arrValue[0],arrValue[1]);
  }catch(err){
    console.error(err)
  }
}

main();

目前使用 Babel 已經支援 ES7 非同步函式的轉碼了,大家可以在自己的專案中開始嘗試。

相關文章