說一說javascript的非同步程式設計

程式碼寫著寫著就懂了發表於2018-08-03

眾所周知javascript是單執行緒的,它的設計之初是為瀏覽器設計的GUI程式語言,GUI程式設計的特性之一是保證UI執行緒一定不能阻塞,否則體驗不佳,甚至介面卡死。

所謂的單執行緒就是一次只能完成一個任務,其任務的排程方式就是排隊,這就和火車站洗手間門口的等待一樣,前面的那個人沒有搞定,你就只能站在後面排隊等著。

圖片來自網路

這種模式的好處是實現起來簡單,執行環境相對單純,壞處就是隻要有一個任務耗時很長,後面的任務都會必須排隊等著,會拖延整個程式的執行。常見的瀏覽器無響應(假死),往往就是因為某一段Javascript程式碼長時間執行(比如死迴圈),導致了整個頁面卡在這個地方,其他任務無法執行。

為了解決這個問題,Javascript語言將任務的執行模式分成兩種:同步(Synchronous)和非同步(Asynchronous)。

“同步”就是上面所說的,後面的任務等待上一個任務結束,然後再執行。

什麼是“非同步”?

所謂非同步簡單說就是一個任務分成兩段,先執行一段,轉而執行其他任務,等做好了準備轉而執行第二段。

以下是當有ABC三個任務,同步或非同步執行的流程圖:

同步

thread ->|----A-----||-----B-----------||-------C------|
複製程式碼

非同步:

A-Start ---------------------------------------- A-End   
           | B-Start ----------------------------------------|--- B-End   
           |   |     C-Start -------------------- C-End      |     |   
           V   V       V                           V         V     V      
  thread-> |-A-|---B---|-C-|-A-|-C-|--A--|-B-|--C--|---A-----|--B--|
複製程式碼

"非同步"非常重要。在瀏覽器端,耗時很長的操作都應該非同步執行,避免瀏覽器失去響應,最好的例子就是Ajax操作。在伺服器端,"非同步模式"甚至是唯一的模式,因為執行環境是單執行緒的,如果允許同步執行所有http請求,伺服器效能會急劇下降,很快就會失去響應。

本文簡單梳理總結了JavaScript非同步函式的發展歷史如下圖:

圖片來自網路

  1. 回撥函式
  2. Promise
  3. Generator+co
  4. async,await

回撥函式Callbacks

似乎一切應該從回撥函式開始談起。

非同步JavaScript

在Javascript 中,非同步程式設計方式只能通過JavaScript中的一等公民函式才能完成:這種方式意味著我們可以將一個函式作為另一個函式的引數,在這個函式的內部可以呼叫被傳遞進來的函式(即回撥函式)。

這也正是回撥函式誕生的原因:如果你將一個函式作為引數傳遞給另一個函式(此時它被稱為高階函式),那麼在函式內部, 你可以呼叫這個函式來完成相應的任務。

回撥函式沒有返回值(不要試圖用return),僅僅被用來在函式內部執行某些動作。

看下面的例子:

step1(function (value1) {
    step2(value1, function(value2) {
        step3(value2, function(value3) {
            step4(value3, function(value4) {
                // Do something with value4
            });
        });
    });
});
複製程式碼

這裡只是做4步,巢狀了4層回撥,如果更多步驟呢?顯然這樣的程式碼只是寫起來比較爽但是缺點也很多。

過度使用回撥函式所會遇到的挑戰:

  • 如果不能合理的組織程式碼,非常容易造成回撥地獄(callback hell),這會使得你的程式碼很難被別人所理解。
  • 不能捕獲異常 (try catch 同步執行,回撥函式會加入佇列,無法捕獲錯誤)
  • 無法使用return語句返回值,並且也不能使用throw關鍵字。

也正是基於這些原因,在JavaScript世界中,一直都在尋找著能夠讓非同步JavaScript開發變得更簡單的可行的方案。這個時候就出現了promise,它解決了上述的問題。

Promise

Promise 的最大優勢是標準化,各類非同步工具庫都按照統一規範實現,即使是async函式也可以無縫整合。所以用 Promise 封裝 API 通用性強,用起來簡單,學習成本低。

一個Promise代表的是一個非同步操作的最終結果。

Promise意味著[許願|承諾]一個還沒有完成的操作,但在未來會完成的。與Promise最主要的互動方法是通過將函式傳入它的then方法從而獲取得Promise最終的值或Promise最終拒絕(reject)的原因。要點有三個:

  • 遞迴,每個非同步操作返回的都是promise物件
  • 狀態機:三種狀態轉換,只在promise物件內部可以控制,外部不能改變狀態
  • 全域性異常處理

1)定義

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, thenif (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});
複製程式碼

每個Promise定義都是一樣的,在建構函式裡傳入一個匿名函式,引數是resolve和reject,分別代表成功和失敗時候的處理。

2) 呼叫

promise.then(function(text){
    console.log(text)// Stuff worked!
    return Promise.reject(new Error('我是故意的'))
}).catch(function(err){
    console.log(err)
})
複製程式碼

它的主要互動方式是通過then函式,如果Promise成功執行resolve了,那麼它就會將resolve的值傳給最近的then函式,作為它的then函式的引數。如果出錯reject,那就交給catch來捕獲異常就好了。

我們可以通過呼叫promise的示例,瞭解一下propmise的一些原理及特性:

普通呼叫例項:

let fs = require('fs');
let p = new Promise(function(resolve,reject){
  fs.readFile('./1.txt','utf8',(err,data)=>{
      err?reject(err):resolve(data);
  })
})

p.then((data)=>{console.log(data)},(err)=>{console.log(err)});
複製程式碼

1.promise例項可以多次呼叫then方法

p.then((data)=>{console.log(data)},(err)=>{console.log(err)});
p.then((data)=>{console.log(data)},(err)=>{console.log(err)});

複製程式碼

2.promise例項可以支援then方法的鏈式呼叫,jquery實現鏈式是通過返回當前的this。但是promise不可以通過返回this來實現。因為後續通過鏈式增加的then不是通過原始的promise物件的狀態來決定走成功還是走失敗的。

p.then((data)=>{console.log(data)},(err)=>{console.log(err)}).then((data)=>{console.log(data)})
複製程式碼

3.只要then方法中的成功回撥和失敗回撥,有返回值(包括undefiend),都會走到下個then方法中的成功回撥中,並且把返回值作為下個then成功回撥的引數傳進去。

第一個then走成功:
p.then((data)=>{return undefined},(err)={console.log()}).then((data)=>{console.log(data)})
輸出:undefiend
第一個then走失敗:
  p.then((data)=>{console.log(1)},(err)={return undefined).then((data)=>{console.log(data)})
輸出:undefiend

複製程式碼

4.只要then方法中的成功回撥和失敗回撥,有一個丟擲異常,則都會走到下一個then中的失敗回撥中

第一個then走成功:
p.then((data)=>{throw new Err("錯誤")},(err)={console.log(1)}).then((data)=>{console.log('成功')},(err)=>{console.log(err)})
輸出:錯誤
第一個then走失敗:
  p.then((data)=>{console.log(1)},(err)={throw new Err("錯誤")).then((data)=>{console.log('成功')},(err)=>{console.log(err)})
輸出:錯誤

複製程式碼

5.成功和失敗 只能走一個,如果成功了,就不會走失敗,如果失敗了,就不會走成功;

6.如果then方法中,返回的不是一個普通值,仍舊是一個promise物件,該如何處理?

答案:它會等待這個promise的執行結果,並且傳給下一個then方法。如果成功,就把這個promise的結果傳給下一個then的成功回撥並且執行,如果失敗就把錯誤傳給下一個then的失敗回撥並且執行。

7.具備catch捕獲錯誤;如果catche前面的所有then方法都沒有失敗回撥,則catche會捕獲到錯誤資訊執行他就是用來兜兒底用的

p是一個失敗的回撥:
p.then((data)=>{console.log('成功')}).then((data)=>{成功}).catche(e){console.log('錯誤')}
複製程式碼

8.返回的結果和 promise是同一個,永遠不會成功和失敗

var  r  = new Promise(function(resolve,reject){
   return r;
})
r.then(function(){
    console.log(1)
},function(err){
    console.log(err)
})
複製程式碼

可以看到結果一直都是pending狀態

圖片來自網路

當你沒有現成的Promise時,你可能需要藉助一些Promise庫,一個流行的選擇是使用 bluebird。 這些庫可能會提供比原生方案更多的功能,並且不侷限於Promise/A+標準所規定的特性。

Generator(ECMAScript6)+co

JavaScript 生成器是個相對較新的概念, 它是ES6(也被稱為ES2015)的新特性。想象下面這樣的一個場景:

當你在執行一個函式的時候,你可以在某個點暫停函式的執行,並且做一些其他工作,然後再返回這個函式繼續執行, 甚至是攜帶一些新的值,然後繼續執行。

上面描述的場景正是JavaScript生成器函式所致力於解決的問題。當我們呼叫一個生成器函式的時候,它並不會立即執行, 而是需要我們手動的去執行迭代操作(next方法)。也就是說,你呼叫生成器函式,它會返回給你一個迭代器。迭代器會遍歷每個中斷點。

function* foo () {  
  var index = 0;
  while (index < 2) {
    yield index++; //暫停函式執行,並執行yield後的操作
  }
}
var bar =  foo(); // 返回的其實是一個迭代器

console.log(bar.next());    // { value: 0, done: false }  
console.log(bar.next());    // { value: 1, done: false }  
console.log(bar.next());    // { value: undefined, done: true }  
複製程式碼

更進一步的,如果你想更輕鬆的使用生成器函式來編寫非同步JavaScript程式碼,我們可以使用 co 這個庫,co是著名的tj大神寫的。

Co是一個為Node.js和瀏覽器打造的基於生成器的流程控制工具,藉助於Promise,你可以使用更加優雅的方式編寫非阻塞程式碼。

使用co,前面的示例程式碼,我們可以使用下面的程式碼來改寫:

co(function* (){  
  yield Something.save();
}).then(function() {
  // success
})
.catch(function(err) {
  //error handling
});
複製程式碼

你可能會問:如何實現並行操作呢?答案可能比你想象的簡單,如下(其實它就是Promise.all而已):

yield [Something.save(), Otherthing.save()];  
複製程式碼

終極解決方案Async/ await

簡而言之,使用async關鍵字,你可以輕鬆地達成之前使用生成器和co函式所做到的工作。

在這背後,async函式實際使用的是Promise,這就是為什麼async函式會返回一個Promise的原因。

因此,我們使用async函式來完成類似於前面程式碼所完成的工作,可以使用下面這樣的方式來重新編寫程式碼:

async function save(Something) {  
  try {
    await Something.save(); // 等待await後面的程式碼執行完,類似於yield
  } catch (ex) {
    //error handling
  }
  console.log('success');
} 
複製程式碼

使用async函式,你需要在函式宣告的最前面加上async關鍵字。這之後,你可以在函式內部使用await關鍵字了,作用和之前的yield作用是類似的。

使用async函式完成並行任務與yiled的方式非常的相似,唯一不同的是,此時Promise.all不再是隱式的,你需要顯示的呼叫它:

async function save(Something) {  
    await Promise.all[Something.save(), Otherthing.save()]
}
複製程式碼

Async/Await是非同步操作的終極解決方案,Koa 2在node 7.6釋出之後,立馬釋出了正式版本,並且推薦使用async函式來編寫Koa中介軟體。

這裡給出一段Koa 2應用裡的一段程式碼:

exports.list = async (ctx, next) => {
  try {
    let students = await Student.getAllAsync();
  
    await ctx.render('students/index', {
      students : students
    })
  } catch (err) {
    return ctx.api_error(err);
  }
};
複製程式碼

它做了3件事兒

  • 通過await Student.getAllAsync();來獲取所有的students資訊。
  • 通過await ctx.render渲染頁面
  • 由於是同步程式碼,使用try/catch做的異常處理

之後還會分享node的基本概念和eventLoop(巨集任務和微任務)

(完)

參考: The Evolution of Asynchronous JavaScript

相關文章