JS中關於異步的那點事~

前言

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 在將來返回可信任的值給then和catch中註冊的 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.all的resolve函數,當有一個失敗,則立即調用Promise.all的reject函數。

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();

參考資料

Promise對象
Nowcoder例題-Promise

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章