Javascript中的異步編程

Javascript最開始是用於瀏覽器中的前端編程語言。Javascript是單線程的,爲了能及時響應用戶操作,javascript對耗時操作(如Ajax請求、本地文件讀取等)的處理是異步進行的,也即是所謂的異步編程。除了快速響應用戶操作之外,另外一個讓javascript採用異步方式的原因是,程序無法預知用戶會進行哪些操作。比如說程序無法提前知道用戶是點“取消”按鈕還是“確定”按鈕。所以,Javascript採用了事件註冊的方式來處理這個問題。在程序編寫時,可以給用戶點擊“取消”按鈕和“確認”按鈕註冊不同的回調函數,這樣當用戶點擊不同的按鈕時,不同的回調函數會被執行。本文從回調函數開始,介紹了Promise、async/await幾種Javascript主要的異步編程方式。

異步編程和回調函數

無論是Ajax請求,還是事件處理,Javascript都是通過回調函數來完成的。談及異步編程和回調函數,可以回想一下操作系統中的中斷及中斷處理程序。由於CPU的速度比外設快出許多,爲了提高CPU的處理效率,計算機系統引入了中斷的概念,外設在讀寫數據的時候,CPU可以忙別的事情,等到外設讀寫完數據後,會給CPU發一箇中斷信號,CPU就可以來執行已經註冊好的、相應的中斷處理程序。Javascript中的回調函數和中斷處理程序都是類似的原理。

先來看一個異步的例子:

console.log("Start...");
setTimeout(()=>{
  console.log("in progress");
}, 2000);
console.log("End...");

如果是同步的話,輸出的順序應該是:

Start...
in progress
End...

然而真實的輸出結果卻是這樣的:

Start...
End...
in progress

原因在於setTimeout中的第一個參數,箭頭函數(即上文所說的回調函數)是異步執行的。setTimeout相當於註冊一個回調函數,該回調函數在2000毫秒(2秒)之後運行。由於是異步的,主程序並不會等到兩秒之後才跑setTimeout後面的代碼,而是立即執行,所以先輸出了End...,2秒之後,註冊的回調函數運行了,輸出了in progress

舉一反三,Ajax請求、事件處理都是類似的。比如:

$.ajax({
  url: url,
  data: data,
  success: ()=>{},
  dataType: dataType
});

$('#mydiv').on('click', ()=>{})

其中的兩個箭頭函數就是回調函數。

當後面的異步操作依賴於前面異步操作的結果時,就需要在回調函數中嵌套回調函數,例如:

console.log("Start...");
setTimeout(()=>{
  console.log('A');
  setTimeout(()=>{
    console.log('AB');
  });
}, 2000);
console.log("End...");

嵌套回調可以保證 AB一定在A之後輸出。

Start...
End...
A
AB

回調函數是Javascript異步編程最基本的編寫方式,但是容易遇到回調地獄的問題。所謂回調地獄,其實就是回調嵌套的太多,導致了代碼難以閱讀和編寫。這是http://callbackhell.com/ 給出的一個例子:

fs.readdir(source, function (err, files) {
  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach(function (filename, fileIndex) {
      console.log(filename)
      gm(source + filename).size(function (err, values) {
        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach(function (width, widthIndex) {
            height = Math.round(width / aspect)
            console.log('resizing ' + filename + 'to ' + height + 'x' + height)
            this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
              if (err) console.log('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
})

Promise

爲了解決回調地獄的問題,Promise被囊括到ES6中。Promise解決回調地獄問題的核心思想是:

  1. 將異步操作的定義和對結果的處理分開來寫
  2. 對結果的處理可以串聯

有點抽象,我們來看一個具體的例子。

console.log("Start...");
let waitOneSecond = new Promise(function(resolve, reject) {
  setTimeout(() => {
    let data = 1;
    resolve(data);
  }, 1000);
});
let waitTenSeconds = new Promise(function(resolve, reject) {
  setTimeout(() => {
    let data = 10;
    resolve(data);
  }, 10000);
});
console.log("Async operation registered...");

waitOneSecond
  .then(data => {
    console.log(`first output: ${data}`);
    return waitTenSeconds;
  })
  .then(data => {
    console.log(`second output: ${data}`);
  });

console.log("End...");

輸出如下:

Start...
Async operation registered...
End...
first output: 1
second output: 10

上面的代碼首先定義了兩個異步操作:waitOneSecond和waitTenSeconds。分別是等待1秒和10秒和把1和10傳給處理函數去處理。直到console.log("Async operation registered...");語句,兩個異步操作都還沒有開始。當執行到waitOneSecond.then時,異步操作纔開始進行,主程序繼續執行,輸出了End...,1秒之後第一個then中註冊的處理函數開始執行,輸出了數字1,然後第二個異步操作waitTenSenconds.then開始執行,10秒後處理函數輸出了數字10.

由此可以看到,兩個異步操作的處理同樣是先後執行,類似於上文例子中先打印A,後打印AB,引入Promise後就避免了嵌套回調,兩個then函數調用串聯起來,從而也就解決了回調地獄的問題。需要注意的是,要想將兩個Promise串聯起來的前提是,第一個Promise的處理函數必須返回一個Promise,如例子中的return waitTenSeconds;

除了解決回調地獄的問題,將異步操作定義和結果處理分開之後,我們可以更加靈活地處理多個異步操作。比如說,

const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise(function(resolve, reject) {
  setTimeout(resolve, 100, 'foo');
});

Promise.all([promise1, promise2, promise3]).then(function(values) {
  console.log(values);
});
// expected output: Array [3, 42, "foo"]

promise1, promise2, promise3將會一起執行,如果都成功,我們可以在then函數中對所有的結果一起進行處理。

再例如:

const promise1 = new Promise(function(resolve, reject) {
    setTimeout(resolve, 500, 'one');
});

const promise2 = new Promise(function(resolve, reject) {
    setTimeout(resolve, 100, 'two');
});

Promise.any([promise1, promise2]).then(function(value) {
  console.log(value);
  // Both resolve, but promise2 is faster
});
// expected output: "two"

如果promise1和promise2有一個已經完成(無論成功或者失敗),就只處理這個已經完成的異步操作。

async/await

ES6引入了迭代器和生成器,yield可以讓程序暫停,而迭代器中的next()又可以程序恢復運行,利用這一點,Javascript便可以讓主程序等待異步操作的完成。這對於習慣其他不使用異步編程語言(例如C語言)的同學來說就非常親切了。而async/await正是利用迭代器和生成器編寫異步函數的語法糖。例如:

let waitTenSeconds = new Promise(function(resolve, reject) {
  setTimeout(() => {
    let data = 10;
    resolve(data);
  }, 10000);
});

async function asyncFunc() {
  console.log("Start...");
  await waitTenSeconds.then(data => {
    console.log(data);
  });
  console.log("End...");
}

asyncFunc();

如果asyncFunc不是async/await函數的話,輸出結果應該是:

Start...
End...
10

因爲asyncFunc是異步操作,主程序會先打印End...,10秒之後纔會打印10。而把asyncFunc改造爲異步函數(即加了async關鍵字)之後,await關鍵字會讓主程序等待waitTenSeconds異步操作執行完成之後才繼續運行,所以輸出結果是:

Start...
10
End...

所以,async函數的寫法其實更像是同步函數。值得注意的是,這樣的寫法雖然更加直觀明瞭,但Javascript的性能主要是靠異步操作來提升的,如果沒有必要,是不建議使用await來等待的。

async/await語法如下:

  • 需要在要異步函數前加上關鍵字async
  • await只能用於async函數中
  • async函數總是返回一個Promise

小結

隨着Javascript語言的發展,異步編程的寫法越來越簡單明瞭,越來越靈活多樣,但無論怎麼變化,回調函數是Javascript實現異步操作最基本的語法,類似於中斷機制的異步原理始終未變。無論技術如何發展,如何變化,但萬變不離其宗,基本原理始終未變。

發佈了69 篇原創文章 · 獲贊 139 · 訪問量 45萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章